karajan-code 4.39.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/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/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +112 -1
- package/src/commands/init.js +6 -2
- 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/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 +4 -2
- package/src/harden/sentinel/sentinel-rules.mjs +86 -0
- package/src/harden/sentinel/sentinel-shell.mjs +17 -0
- package/src/harden/sentinel/sessionstart.mjs +18 -9
- package/src/harden/sentinel-hooks.js +23 -2
- 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 +12 -2
- package/src/privacy/scan.js +7 -0
- package/src/review/gate-gitignore.js +8 -0
- 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/config-resolver.js +19 -1
- package/src/utils/run-log.js +74 -2
- package/src/utils/stack-detect.js +12 -0
package/package.json
CHANGED
|
@@ -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" };
|
|
@@ -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")
|
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");
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj rules compile (KJC-TSK-0940, MDR-D, ADR 0016): the brief for whoever
|
|
3
|
+
* compiles the rules of the MD files that have no gate yet. kj calls no model:
|
|
4
|
+
* the host agent knows its own tools and their arguments, which is what a
|
|
5
|
+
* condition has to name. It proposes; `kj rules check` proves the proposal and
|
|
6
|
+
* the user approves it (`kj rules approve`, a human act).
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import yaml from "js-yaml";
|
|
13
|
+
|
|
14
|
+
import { shownSource } from "../rules/inventory.js";
|
|
15
|
+
import { loadRules, PROPOSAL_FILE, RULES_FILE, rulesCoverage } from "./rules.js";
|
|
16
|
+
|
|
17
|
+
const FORMAT = `\`\`\`yaml
|
|
18
|
+
version: 1
|
|
19
|
+
rules:
|
|
20
|
+
- id: R-0a1b2c3d4e # the id below, as is
|
|
21
|
+
source: CLAUDE.md # the MD file the rule is written in
|
|
22
|
+
text: "Sprints: uno por día." # the rule's text, word for word
|
|
23
|
+
kind: deterministic
|
|
24
|
+
when: # WHEN TO DENY the call
|
|
25
|
+
tool: "mcp__planning-game*__create_sprint" # a glob, or a list of globs
|
|
26
|
+
any: # all: every condition holds; any: at least one
|
|
27
|
+
- { arg: allowLongSprint, equals: true }
|
|
28
|
+
- { arg: endDate, days_from: startDate, gt: 0 }
|
|
29
|
+
message: "Un sprint dura un día." # what the denied agent reads
|
|
30
|
+
examples: # the rule is run against them: both lists required
|
|
31
|
+
deny: [{ tool: mcp__planning-game-x__create_sprint, input: { startDate: "2026-10-05", endDate: "2026-10-11" } }]
|
|
32
|
+
allow: [{ tool: mcp__planning-game-x__create_sprint, input: { startDate: "2026-10-05", endDate: "2026-10-05" } }]
|
|
33
|
+
- { id: R-1a2b3c4d5e, source: CLAUDE.md, text: "No preguntes obviedades.", kind: judgment, when: { tool: AskUserQuestion } }
|
|
34
|
+
- { id: R-2a3b4c5d6e, source: CLAUDE.md, text: "Habla español correcto.", kind: out-of-scope, reason: "no tool call breaks it" }
|
|
35
|
+
\`\`\``;
|
|
36
|
+
|
|
37
|
+
const HOW = [
|
|
38
|
+
"A condition reads ONE argument of the tool input (`arg`, a dotted path such as updates.status) with exactly one",
|
|
39
|
+
"operator: equals, in (a list), matches (a regex), exists (true/false), gt, lt. With days_from, gt/lt compare the",
|
|
40
|
+
"calendar days from that other argument to `arg`. There is nothing else: no other keys, no free code.",
|
|
41
|
+
"",
|
|
42
|
+
"Choose the kind of each rule:",
|
|
43
|
+
"- deterministic: ONE tool call breaks it and the tool name and its arguments are enough to tell. Name the tools",
|
|
44
|
+
" as you see them (MCP tools included). Deny only what the rule forbids: a gate that denies honest calls gets removed.",
|
|
45
|
+
"- judgment: a tool call breaks it, but telling takes reading what the call says. Give `when.tool` only.",
|
|
46
|
+
"- out-of-scope: it is about how you think or answer, and no tool call breaks it. Give the `reason`.",
|
|
47
|
+
"Do not stretch a rule into a condition it does not state, and do not leave a rule out: every rule gets a kind.",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* KJC-TSK-0953: kj writes the skeleton of the proposal, so nobody retypes the
|
|
52
|
+
* literal texts. It starts from what rules.yml holds minus the stale, lays the
|
|
53
|
+
* proposal already there over it (what is filled in stays), and adds one entry
|
|
54
|
+
* per pending rule that is missing, with no kind yet.
|
|
55
|
+
* @returns {number|null} the entries with no kind, or null when the proposal there is not YAML
|
|
56
|
+
*/
|
|
57
|
+
function writeSkeleton({ projectDir, home, pending, kept, gone }) {
|
|
58
|
+
const file = path.join(projectDir, PROPOSAL_FILE);
|
|
59
|
+
let proposed = [];
|
|
60
|
+
if (fs.existsSync(file)) {
|
|
61
|
+
try { proposed = yaml.load(fs.readFileSync(file, "utf8"))?.rules; } catch { return null; }
|
|
62
|
+
if (!Array.isArray(proposed)) return null;
|
|
63
|
+
}
|
|
64
|
+
// The approved rules are the base: a proposal that forgot one would remove its
|
|
65
|
+
// gate on approval. What the proposal says about a rule wins; the stale leave.
|
|
66
|
+
const byId = new Map(kept.map((rule) => [rule.id, rule]));
|
|
67
|
+
const loose = []; // entries with no usable id: kept, for kj rules check to name
|
|
68
|
+
for (const entry of proposed) {
|
|
69
|
+
if (typeof entry?.id !== "string") loose.push(entry);
|
|
70
|
+
else if (!gone.has(entry.id)) byId.set(entry.id, entry);
|
|
71
|
+
}
|
|
72
|
+
for (const rule of pending) {
|
|
73
|
+
if (!byId.has(rule.id)) byId.set(rule.id, { id: rule.id, source: shownSource(rule.file, projectDir, home), text: rule.text });
|
|
74
|
+
}
|
|
75
|
+
const rules = [...byId.values(), ...loose];
|
|
76
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
77
|
+
fs.writeFileSync(file, yaml.dump({ version: 1, rules }, { lineWidth: -1 }));
|
|
78
|
+
return rules.filter((entry) => !entry?.kind).length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @returns {{code: 0|1, lines: string[]}} */
|
|
82
|
+
export function rulesCompileBrief({ projectDir, home = os.homedir() }) {
|
|
83
|
+
const covered = rulesCoverage({ projectDir, home });
|
|
84
|
+
if (!covered.output) return covered;
|
|
85
|
+
const pending = covered.output.rows.filter((row) => row.status === "none");
|
|
86
|
+
const { stale } = covered.output;
|
|
87
|
+
if (pending.length + stale.length === 0) return { code: 0, lines: ["✓ every rule of the MD files is decided: nothing to compile"] };
|
|
88
|
+
const gone = new Set(stale.map((rule) => rule.id));
|
|
89
|
+
const kept = loadRules(projectDir).rules.filter((rule) => !gone.has(rule.id));
|
|
90
|
+
const undecided = writeSkeleton({ projectDir, home, pending, kept, gone });
|
|
91
|
+
if (undecided === null) return { code: 1, lines: [`✗ ${PROPOSAL_FILE} is not valid YAML with a rules list: fix it or delete it`] };
|
|
92
|
+
return {
|
|
93
|
+
code: 0,
|
|
94
|
+
lines: [
|
|
95
|
+
"# Compile the rules of the MD files into gates (ADR 0016)",
|
|
96
|
+
"",
|
|
97
|
+
`${PROPOSAL_FILE} is written: what ${RULES_FILE} holds today, as it is, and one entry per rule with no gate,`,
|
|
98
|
+
`with its id, source and literal text. ${undecided} entr${undecided === 1 ? "y has" : "ies have"} no kind yet: edit each one, give it its kind and`,
|
|
99
|
+
`what that kind takes. Leave id, source and text as they are. You propose; you cannot write ${RULES_FILE}.`,
|
|
100
|
+
...(stale.length ? [`Left out, their text is in no MD any more: ${stale.map((rule) => rule.id).join(", ")}`] : []),
|
|
101
|
+
"",
|
|
102
|
+
FORMAT,
|
|
103
|
+
"",
|
|
104
|
+
...HOW,
|
|
105
|
+
"",
|
|
106
|
+
"A rule with a condition (deterministic) you write by hand, in its entry. The rest take no condition, and you",
|
|
107
|
+
"decide many at once: `kj rules decide <id>... --kind judgment --tool <tool>` or",
|
|
108
|
+
"`kj rules decide <id>... --kind out-of-scope --reason \"<why no tool call breaks it>\"`.",
|
|
109
|
+
"",
|
|
110
|
+
"## Then",
|
|
111
|
+
"Run `kj rules check` and fix the proposal until it holds. Then run `kj rules review`: a different AI judges",
|
|
112
|
+
"whether each compilation is as strong as its text, and a rule you weakened comes back to you. Only then ask your",
|
|
113
|
+
"user to read it and run `kj rules approve` from their own terminal: no agent session can.",
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj rules decide (KJC-TSK-0969, MDR-G, ADR 0016): a real project has more than
|
|
3
|
+
* a hundred rules, and most take no condition. Their kind is decided in one go:
|
|
4
|
+
* judgment with the tool it shows on, or out of scope with the reason. kj writes
|
|
5
|
+
* the proposal, as it writes its skeleton. A deterministic rule is not decided
|
|
6
|
+
* here: its condition and its examples are written rule by rule.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import yaml from "js-yaml";
|
|
12
|
+
|
|
13
|
+
import { PROPOSAL_FILE } from "./rules.js";
|
|
14
|
+
|
|
15
|
+
const failed = (line) => ({ code: 1, lines: [`✗ ${line}`] });
|
|
16
|
+
|
|
17
|
+
/** What each kind takes, or why it cannot be decided this way. */
|
|
18
|
+
function decision({ kind, tools = [], reason = "" }) {
|
|
19
|
+
if (kind === "judgment") {
|
|
20
|
+
if (tools.length === 0) return { error: "a judgment rule names the tool it shows on: --tool <glob> (repeatable)" };
|
|
21
|
+
return { fields: { kind, when: { tool: tools.length === 1 ? tools[0] : tools } } };
|
|
22
|
+
}
|
|
23
|
+
if (kind === "out-of-scope") {
|
|
24
|
+
if (!reason.trim()) return { error: "an out-of-scope rule says why no tool call breaks it: --reason <text>" };
|
|
25
|
+
return { fields: { kind, reason: reason.trim() } };
|
|
26
|
+
}
|
|
27
|
+
return { error: "--kind is judgment or out-of-scope; a deterministic rule is written by hand, with its condition and its examples" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{projectDir: string, file?: string, ids: string[], kind: string, tools?: string[], reason?: string}} opts
|
|
32
|
+
* @returns {{code: 0|1, lines: string[]}}
|
|
33
|
+
*/
|
|
34
|
+
export function rulesDecide({ projectDir, file = PROPOSAL_FILE, ids, kind, tools, reason }) {
|
|
35
|
+
if (!Array.isArray(ids) || ids.length === 0) return failed("name at least one rule id (R-...)");
|
|
36
|
+
const { fields, error } = decision({ kind, tools, reason });
|
|
37
|
+
if (error) return failed(error);
|
|
38
|
+
const target = path.resolve(projectDir, file);
|
|
39
|
+
let doc;
|
|
40
|
+
try { doc = yaml.load(fs.readFileSync(target, "utf8")); } catch { return failed(`no readable ${file}: run \`kj rules compile\` first`); }
|
|
41
|
+
if (!Array.isArray(doc?.rules)) return failed(`${file} has no rules list`);
|
|
42
|
+
const wanted = new Set(ids);
|
|
43
|
+
const known = new Set(doc.rules.map((entry) => entry?.id));
|
|
44
|
+
const missing = ids.filter((id) => !known.has(id));
|
|
45
|
+
if (missing.length) return failed(`not in ${file}: ${missing.join(", ")}`);
|
|
46
|
+
// What identifies the rule stays; what its previous kind took goes.
|
|
47
|
+
doc.rules = doc.rules.map((entry) => (wanted.has(entry?.id) ? { id: entry.id, source: entry.source, text: entry.text, ...fields } : entry));
|
|
48
|
+
fs.writeFileSync(target, yaml.dump(doc, { lineWidth: -1 }));
|
|
49
|
+
const undecided = doc.rules.filter((entry) => !entry?.kind).length;
|
|
50
|
+
return { code: 0, lines: [`✓ ${wanted.size} rule(s) decided as ${kind}; ${undecided} entr${undecided === 1 ? "y has" : "ies have"} no kind yet`] };
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj rules review (KJC-TSK-0963, MDR-F4, ADR 0017): the agent the rules will
|
|
3
|
+
* watch wrote the proposal, so it is not the one who calls it good. A DIFFERENT
|
|
4
|
+
* AI reads it rule by rule, and its verdict is tied to the exact bytes of the
|
|
5
|
+
* proposal (the same store and the same primitive as `kj review`). Without an
|
|
6
|
+
* approved verdict for those bytes, `kj rules approve` does not offer it.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { runOneShotReview } from "../review/one-shot-review.js";
|
|
13
|
+
import { PROPOSAL_FILE, rulesCheck } from "./rules.js";
|
|
14
|
+
|
|
15
|
+
const TASK = [
|
|
16
|
+
"This is NOT code: it is a proposal of compiled rules (YAML). Each entry carries the literal `text` of a rule the user",
|
|
17
|
+
"wrote in their MD files (already verified word for word) and what an agent compiled it into. That agent is the one these",
|
|
18
|
+
"rules are going to watch, so it gains from a compilation that never fires. Judge every rule on one question: does the",
|
|
19
|
+
"compilation enforce what the text says?",
|
|
20
|
+
"- kind deterministic: `when` must deny the tool calls the text forbids. Too narrow a condition (it would rarely fire), or",
|
|
21
|
+
" examples picked so that a weak condition passes, is a defect. So is a condition so wide that it denies honest calls.",
|
|
22
|
+
"- kind judgment or out-of-scope: nothing is blocked on these. If a condition over a tool name and its arguments could",
|
|
23
|
+
" have enforced the text, the rule has been weakened.",
|
|
24
|
+
"Report as a BLOCKING issue every rule that is weaker than its text, naming its id (R-...) and saying what would enforce it.",
|
|
25
|
+
"Approve only when no rule is weaker than its text.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{projectDir: string, file?: string, home?: string, config: object, logger?: object, deps?: object}} opts
|
|
30
|
+
* `deps` are the seams of runOneShotReview (hostAgent, createAgentFn, detectAgents).
|
|
31
|
+
* @returns {Promise<{code: 0|1, lines: string[]}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function rulesReview({ projectDir, file = PROPOSAL_FILE, home = os.homedir(), config, logger, deps = {} }) {
|
|
34
|
+
let text;
|
|
35
|
+
try { text = fs.readFileSync(path.resolve(projectDir, file), "utf8"); } catch { return { code: 1, lines: [`✗ no ${file}: nothing to review`] }; }
|
|
36
|
+
const checked = rulesCheck({ projectDir, home, text });
|
|
37
|
+
if (checked.code !== 0) return { code: 1, lines: checked.lines };
|
|
38
|
+
const record = await runOneShotReview({ diff: text, task: TASK, config, logger, projectDir, ...deps });
|
|
39
|
+
if (record.verdict === "approved") {
|
|
40
|
+
return { code: 0, lines: [`✓ APPROVED by ${record.reviewer}: ${record.summary || "the compilation enforces the rules as written"}`, "Now your user reads it and runs `kj rules approve` from their own terminal."] };
|
|
41
|
+
}
|
|
42
|
+
const issues = record.issues.map((issue) => ` - ${issue.description ?? issue.message ?? JSON.stringify(issue)}`);
|
|
43
|
+
return { code: 1, lines: [`✗ REJECTED by ${record.reviewer} — ${issues.length} rule(s) weaker than their text:`, ...issues, "Fix the proposal and run `kj rules review` again: the verdict is tied to its exact content."] };
|
|
44
|
+
}
|