karajan-code 4.39.0 → 4.41.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/bootstrap.js +12 -41
- package/src/commands/env.js +8 -0
- package/src/commands/init.js +6 -2
- package/src/commands/rules-approve.js +63 -0
- package/src/commands/rules-compile.js +117 -0
- package/src/commands/rules-decide.js +52 -0
- package/src/commands/rules-review.js +57 -0
- package/src/commands/rules.js +169 -0
- package/src/config/loader.js +23 -1
- package/src/environment/adr.js +5 -2
- package/src/environment/contract-commit.js +79 -0
- 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 +54 -0
- package/src/rules/compiled.js +111 -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")
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
* first and a git that cannot run is the end of the line, not a warning.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { execFileSync } from "node:child_process";
|
|
19
18
|
import { existsSync } from "node:fs";
|
|
20
19
|
import { join } from "node:path";
|
|
21
20
|
import { ensureGitRepo } from "./init.js";
|
|
22
21
|
import { initCommand } from "./init.js";
|
|
23
22
|
import { envInstallCommand } from "./env.js";
|
|
23
|
+
import { commitContract, contractChanges } from "../environment/contract-commit.js";
|
|
24
24
|
import { runStartScript, START_SCRIPT_CONTRACT } from "../start/project-script.js";
|
|
25
25
|
|
|
26
26
|
const STEP_LABEL = {
|
|
@@ -31,25 +31,6 @@ const STEP_LABEL = {
|
|
|
31
31
|
start: "arranque del proyecto",
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
/** What kj generates and the whole team must inherit by cloning. */
|
|
35
|
-
const CONTRACT_PATHS = [
|
|
36
|
-
".gitignore",
|
|
37
|
-
".karajan/hooks",
|
|
38
|
-
".karajan/review-gate",
|
|
39
|
-
".karajan/adrs",
|
|
40
|
-
".karajan/policy.yml",
|
|
41
|
-
".claude",
|
|
42
|
-
"CLAUDE.md",
|
|
43
|
-
"AGENTS.md",
|
|
44
|
-
"GEMINI.md",
|
|
45
|
-
// KJC-TSK-0879: in a Rulesync repo kj's rules live in .rulesync/rules/karajan.md.
|
|
46
|
-
".rulesync",
|
|
47
|
-
];
|
|
48
|
-
const CONTRACT_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
|
|
49
|
-
|
|
50
|
-
const hasCommits = (projectDir, git) => {
|
|
51
|
-
try { git(["rev-parse", "--verify", "HEAD"]); return true; } catch { return false; }
|
|
52
|
-
};
|
|
53
34
|
|
|
54
35
|
/**
|
|
55
36
|
* @returns {Promise<{ok: boolean, pending: string|null, steps: Array<{name: string, status: "done"|"already"|"pending"}>}>}
|
|
@@ -75,6 +56,8 @@ export async function bootstrapCommand({ config = {}, logger = console, flags =
|
|
|
75
56
|
return stop("git", "git no está disponible y las garantías de Karajan viven en sus hooks");
|
|
76
57
|
}
|
|
77
58
|
say("git", hadRepo ? "already" : "done", hadRepo ? "ya existía" : "creado, sin commit todavía");
|
|
59
|
+
// What is dirty NOW is the person's: kj commits only what it generates below.
|
|
60
|
+
const before = contractChanges(projectDir);
|
|
78
61
|
|
|
79
62
|
// 2. The project's own configuration.
|
|
80
63
|
const hasConfig = existsSync(join(projectDir, ".karajan", "kj.config.yml"));
|
|
@@ -98,27 +81,15 @@ export async function bootstrapCommand({ config = {}, logger = console, flags =
|
|
|
98
81
|
// 4. The contract commit. project-new.md asked the USER for it, and it was
|
|
99
82
|
// the commit their own freshly installed gates rejected: the review gate
|
|
100
83
|
// (KJC-BUG-0165) and the branch guard (KJC-BUG-0186) both exempt it now,
|
|
101
|
-
// so kj
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// act with its own four layers (ADR 0009).
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
else
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
else {
|
|
111
|
-
try {
|
|
112
|
-
git(["add", "--", ...present]);
|
|
113
|
-
git(["commit", "-m", CONTRACT_MESSAGE]);
|
|
114
|
-
} catch (err) {
|
|
115
|
-
// Nunca explotar aquí: lo más probable es que falte la identidad del
|
|
116
|
-
// clon, y ese cauce ya lo pide el paso anterior (KJC-BUG-0188).
|
|
117
|
-
return stop("contract", `git no pudo commitear el contrato: ${String(err.message).split("\n")[0]}`);
|
|
118
|
-
}
|
|
119
|
-
say("contract", "done", `${present.length} ruta(s) del contrato`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
84
|
+
// so kj makes it itself (src/environment/contract-commit.js). Only what
|
|
85
|
+
// kj generated, never the person's own code, never on the base branch
|
|
86
|
+
// once there is history (KJC-BUG-0273). This is NOT the supervisor seal,
|
|
87
|
+
// which stays a human act with its own four layers (ADR 0009).
|
|
88
|
+
const contract = commitContract({ projectDir, before, baseBranch: config.base_branch || "main" });
|
|
89
|
+
if (contract.committed) say("contract", "done", `${contract.files.length} ruta(s) del contrato`);
|
|
90
|
+
else if (contract.reason.startsWith("nothing")) say("contract", "already", "no hay contrato que commitear");
|
|
91
|
+
// Lo más probable: la identidad del clon (KJC-BUG-0188) o la rama base.
|
|
92
|
+
else return stop("contract", contract.reason);
|
|
122
93
|
|
|
123
94
|
// 5. Does the project actually run? kj verified the method; nobody verified
|
|
124
95
|
// the application (BOOT-C, KJC-TSK-0862). It REPORTS, never blocks: a
|
package/src/commands/env.js
CHANGED
|
@@ -21,6 +21,7 @@ import { reviewGateCommand } from "./review-gate.js";
|
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { openProjectStore, projectDbPath } from "../rag/project-store.js";
|
|
23
23
|
import { maybeRulesyncGenerate } from "../utils/rulesync.js";
|
|
24
|
+
import { commitContract, contractChanges } from "../environment/contract-commit.js";
|
|
24
25
|
|
|
25
26
|
function hasRagIndex(config, projectDir) {
|
|
26
27
|
// KJC-BUG-0128: probing must never CREATE the store — openVecStore runs
|
|
@@ -52,6 +53,8 @@ export function briefCommand({ config = null, flags = {}, role = null }) {
|
|
|
52
53
|
|
|
53
54
|
export async function envInstallCommand({ config = null, logger = null, flags = {} }) {
|
|
54
55
|
const projectDir = config?.projectDir || process.cwd();
|
|
56
|
+
// KJC-BUG-0273: what is dirty NOW is the person's; kj commits only what it generates below.
|
|
57
|
+
const dirtyBefore = contractChanges(projectDir);
|
|
55
58
|
|
|
56
59
|
// KJC-TSK-0709 — the board is chosen BEFORE the playbook renders (its
|
|
57
60
|
// tracking line depends on the backend): interactive installs ask when
|
|
@@ -214,6 +217,11 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
214
217
|
wizard?.close();
|
|
215
218
|
} catch { /* privacy onboarding is best-effort */ }
|
|
216
219
|
console.log(" The host agent now follows the method: RAG first, TDD, cross-AI review before commit.");
|
|
220
|
+
// KJC-BUG-0273: the contract is kj's to commit. Left to the agent, its own
|
|
221
|
+
// PR-size rules forbid a 1200-line commit of generated files and it stops.
|
|
222
|
+
const contract = commitContract({ projectDir, before: dirtyBefore, baseBranch: config?.base_branch || "main" });
|
|
223
|
+
if (contract.committed) console.log(`✓ contract committed by kj: ${contract.files.length} generated file(s), nothing for the agent to commit`);
|
|
224
|
+
else if (!contract.reason.startsWith("nothing")) console.log(`⚠ the contract kj generated is NOT committed (${contract.reason}): run \`kj env install\` again once that is fixed`);
|
|
217
225
|
// KJC-BUG-0133 (part 2): a mid-session install lands the playbook in
|
|
218
226
|
// CLAUDE.md, but the running session loaded its context BEFORE — nobody
|
|
219
227
|
// re-reads it. Print the method so it enters THIS conversation now.
|
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,63 @@
|
|
|
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 read these exact bytes; a touched proposal is reviewed again.
|
|
36
|
+
const { ok, verdict } = await checkVerdict(projectDir, text);
|
|
37
|
+
if (!verdict) return { code: 1, lines: ["✗ this proposal has no cross-AI review of its exact content (none recorded, or it changed since): run `kj rules review`"] };
|
|
38
|
+
if (ok) {
|
|
39
|
+
log(`Reviewed by ${verdict.reviewer}, a different AI from the one that wrote it: ${verdict.summary || "approved"}`);
|
|
40
|
+
} else {
|
|
41
|
+
// KJC-TSK-0974 (ADR 0017): a rejection does not decide, the human does. What
|
|
42
|
+
// the defense asks is that nothing weak is approved UNSEEN: objections first.
|
|
43
|
+
log(`REJECTED by ${verdict.reviewer}, a different AI from the one that wrote it: ${verdict.summary || "see its objections"}`);
|
|
44
|
+
log("Its objections, before anything else. Approving installs the proposal as it is, objections included:");
|
|
45
|
+
for (const issue of verdict.issues ?? []) log(` - ${issue.description ?? issue.message ?? JSON.stringify(issue)}`);
|
|
46
|
+
}
|
|
47
|
+
const { rules, local } = checked;
|
|
48
|
+
// KJC-TSK-0962: read in the order of what can hurt, weakened rules first.
|
|
49
|
+
const view = approvalView(rules, loadRules(projectDir).rules, local, { versioned: RULES_FILE, unversioned: LOCAL_RULES_FILE });
|
|
50
|
+
for (const line of view) log(line);
|
|
51
|
+
confirmHuman(ACT, deps.confirm);
|
|
52
|
+
// KJC-TSK-0961 (ADR 0017): a rule written only in the user's private MD files
|
|
53
|
+
// is not versioned. Where it goes is the inventory's word, not the proposal's.
|
|
54
|
+
const parts = [[RULES_FILE, rules.filter((rule) => !local.has(rule.id))], [LOCAL_RULES_FILE, rules.filter((rule) => local.has(rule.id))]];
|
|
55
|
+
for (const [name, part] of parts) {
|
|
56
|
+
const target = path.join(projectDir, name);
|
|
57
|
+
if (part.length) fs.writeFileSync(target, yaml.dump({ version: 1, rules: part }, { lineWidth: -1 }));
|
|
58
|
+
else fs.rmSync(target, { force: true }); // no rule left for this file: none stays behind
|
|
59
|
+
}
|
|
60
|
+
fs.rmSync(proposal, { force: true });
|
|
61
|
+
const [[, versioned], [, kept]] = parts;
|
|
62
|
+
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)`] };
|
|
63
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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`, and a `reason`",
|
|
46
|
+
" when a condition looks possible and is not (a reviewer will ask): say what it cannot see or what it would deny.",
|
|
47
|
+
"- out-of-scope: it is about how you think or answer, and no tool call breaks it. Give the `reason`.",
|
|
48
|
+
"Do not stretch a rule into a condition it does not state, and do not leave a rule out: every rule gets a kind.",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* KJC-TSK-0953: kj writes the skeleton of the proposal, so nobody retypes the
|
|
53
|
+
* literal texts. It starts from what rules.yml holds minus the stale, lays the
|
|
54
|
+
* proposal already there over it (what is filled in stays), and adds one entry
|
|
55
|
+
* per pending rule that is missing, with no kind yet.
|
|
56
|
+
* @returns {number|null} the entries with no kind, or null when the proposal there is not YAML
|
|
57
|
+
*/
|
|
58
|
+
function writeSkeleton({ projectDir, home, pending, kept, gone }) {
|
|
59
|
+
const file = path.join(projectDir, PROPOSAL_FILE);
|
|
60
|
+
let proposed = [];
|
|
61
|
+
if (fs.existsSync(file)) {
|
|
62
|
+
try { proposed = yaml.load(fs.readFileSync(file, "utf8"))?.rules; } catch { return null; }
|
|
63
|
+
if (!Array.isArray(proposed)) return null;
|
|
64
|
+
}
|
|
65
|
+
// The approved rules are the base: a proposal that forgot one would remove its
|
|
66
|
+
// gate on approval. What the proposal says about a rule wins; the stale leave.
|
|
67
|
+
const byId = new Map(kept.map((rule) => [rule.id, rule]));
|
|
68
|
+
const loose = []; // entries with no usable id: kept, for kj rules check to name
|
|
69
|
+
for (const entry of proposed) {
|
|
70
|
+
if (typeof entry?.id !== "string") loose.push(entry);
|
|
71
|
+
else if (!gone.has(entry.id)) byId.set(entry.id, entry);
|
|
72
|
+
}
|
|
73
|
+
for (const rule of pending) {
|
|
74
|
+
if (!byId.has(rule.id)) byId.set(rule.id, { id: rule.id, source: shownSource(rule.file, projectDir, home), text: rule.text });
|
|
75
|
+
}
|
|
76
|
+
const rules = [...byId.values(), ...loose];
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
fs.writeFileSync(file, yaml.dump({ version: 1, rules }, { lineWidth: -1 }));
|
|
79
|
+
return rules.filter((entry) => !entry?.kind).length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @returns {{code: 0|1, lines: string[]}} */
|
|
83
|
+
export function rulesCompileBrief({ projectDir, home = os.homedir() }) {
|
|
84
|
+
const covered = rulesCoverage({ projectDir, home });
|
|
85
|
+
if (!covered.output) return covered;
|
|
86
|
+
const pending = covered.output.rows.filter((row) => row.status === "none");
|
|
87
|
+
const { stale } = covered.output;
|
|
88
|
+
if (pending.length + stale.length === 0) return { code: 0, lines: ["✓ every rule of the MD files is decided: nothing to compile"] };
|
|
89
|
+
const gone = new Set(stale.map((rule) => rule.id));
|
|
90
|
+
const kept = loadRules(projectDir).rules.filter((rule) => !gone.has(rule.id));
|
|
91
|
+
const undecided = writeSkeleton({ projectDir, home, pending, kept, gone });
|
|
92
|
+
if (undecided === null) return { code: 1, lines: [`✗ ${PROPOSAL_FILE} is not valid YAML with a rules list: fix it or delete it`] };
|
|
93
|
+
return {
|
|
94
|
+
code: 0,
|
|
95
|
+
lines: [
|
|
96
|
+
"# Compile the rules of the MD files into gates (ADR 0016)",
|
|
97
|
+
"",
|
|
98
|
+
`${PROPOSAL_FILE} is written: what ${RULES_FILE} holds today, as it is, and one entry per rule with no gate,`,
|
|
99
|
+
`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`,
|
|
100
|
+
`what that kind takes. Leave id, source and text as they are. You propose; you cannot write ${RULES_FILE}.`,
|
|
101
|
+
...(stale.length ? [`Left out, their text is in no MD any more: ${stale.map((rule) => rule.id).join(", ")}`] : []),
|
|
102
|
+
"",
|
|
103
|
+
FORMAT,
|
|
104
|
+
"",
|
|
105
|
+
...HOW,
|
|
106
|
+
"",
|
|
107
|
+
"A rule with a condition (deterministic) you write by hand, in its entry. The rest take no condition, and you",
|
|
108
|
+
"decide many at once: `kj rules decide <id>... --kind judgment --tool <tool>` or",
|
|
109
|
+
"`kj rules decide <id>... --kind out-of-scope --reason \"<why no tool call breaks it>\"`.",
|
|
110
|
+
"",
|
|
111
|
+
"## Then",
|
|
112
|
+
"Run `kj rules check` and fix the proposal until it holds. Then run `kj rules review`: a different AI judges",
|
|
113
|
+
"whether each compilation is as strong as its text, and a rule you weakened comes back to you. Only then ask your",
|
|
114
|
+
"user to read it and run `kj rules approve` from their own terminal: no agent session can.",
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
// KJC-TSK-0973: why it takes no condition, when there is something to say.
|
|
22
|
+
return { fields: { kind, when: { tool: tools.length === 1 ? tools[0] : tools }, ...(reason.trim() ? { reason: reason.trim() } : {}) } };
|
|
23
|
+
}
|
|
24
|
+
if (kind === "out-of-scope") {
|
|
25
|
+
if (!reason.trim()) return { error: "an out-of-scope rule says why no tool call breaks it: --reason <text>" };
|
|
26
|
+
return { fields: { kind, reason: reason.trim() } };
|
|
27
|
+
}
|
|
28
|
+
return { error: "--kind is judgment or out-of-scope; a deterministic rule is written by hand, with its condition and its examples" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{projectDir: string, file?: string, ids: string[], kind: string, tools?: string[], reason?: string}} opts
|
|
33
|
+
* @returns {{code: 0|1, lines: string[]}}
|
|
34
|
+
*/
|
|
35
|
+
export function rulesDecide({ projectDir, file = PROPOSAL_FILE, ids, kind, tools, reason }) {
|
|
36
|
+
if (!Array.isArray(ids) || ids.length === 0) return failed("name at least one rule id (R-...)");
|
|
37
|
+
const { fields, error } = decision({ kind, tools, reason });
|
|
38
|
+
if (error) return failed(error);
|
|
39
|
+
const target = path.resolve(projectDir, file);
|
|
40
|
+
let doc;
|
|
41
|
+
try { doc = yaml.load(fs.readFileSync(target, "utf8")); } catch { return failed(`no readable ${file}: run \`kj rules compile\` first`); }
|
|
42
|
+
if (!Array.isArray(doc?.rules)) return failed(`${file} has no rules list`);
|
|
43
|
+
const wanted = new Set(ids);
|
|
44
|
+
const known = new Set(doc.rules.map((entry) => entry?.id));
|
|
45
|
+
const missing = ids.filter((id) => !known.has(id));
|
|
46
|
+
if (missing.length) return failed(`not in ${file}: ${missing.join(", ")}`);
|
|
47
|
+
// What identifies the rule stays; what its previous kind took goes.
|
|
48
|
+
doc.rules = doc.rules.map((entry) => (wanted.has(entry?.id) ? { id: entry.id, source: entry.source, text: entry.text, ...fields } : entry));
|
|
49
|
+
fs.writeFileSync(target, yaml.dump(doc, { lineWidth: -1 }));
|
|
50
|
+
const undecided = doc.rules.filter((entry) => !entry?.kind).length;
|
|
51
|
+
return { code: 0, lines: [`✓ ${wanted.size} rule(s) decided as ${kind}; ${undecided} entr${undecided === 1 ? "y has" : "ies have"} no kind yet`] };
|
|
52
|
+
}
|