karajan-code 4.40.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.40.0",
3
+ "version": "4.41.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -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 can make it itself instead of leaving the person to fight it.
102
- // Only what kj generated, only while the repo has no commit, never the
103
- // person's own code. This is NOT the supervisor seal, which stays a human
104
- // act with its own four layers (ADR 0009).
105
- const git = deps.gitRun ?? ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
106
- if (hasCommits(projectDir, git)) say("contract", "already", "el repositorio ya tiene historia");
107
- else {
108
- const present = CONTRACT_PATHS.filter((p) => existsSync(join(projectDir, p)));
109
- if (present.length === 0) say("contract", "already", "no hay contrato que commitear");
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
@@ -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.
@@ -32,14 +32,18 @@ export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env
32
32
  const checked = rulesCheck({ projectDir, home, text });
33
33
  if (checked.code !== 0) return { code: 1, lines: checked.lines };
34
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] };
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)}`);
41
46
  }
42
- log(`Reviewed by ${reviewed.verdict.reviewer}, a different AI from the one that wrote it: ${reviewed.verdict.summary || "approved"}`);
43
47
  const { rules, local } = checked;
44
48
  // KJC-TSK-0962: read in the order of what can hurt, weakened rules first.
45
49
  const view = approvalView(rules, loadRules(projectDir).rules, local, { versioned: RULES_FILE, unversioned: LOCAL_RULES_FILE });
@@ -42,7 +42,8 @@ const HOW = [
42
42
  "Choose the kind of each rule:",
43
43
  "- deterministic: ONE tool call breaks it and the tool name and its arguments are enough to tell. Name the tools",
44
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.",
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.",
46
47
  "- out-of-scope: it is about how you think or answer, and no tool call breaks it. Give the `reason`.",
47
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.",
48
49
  ];
@@ -18,7 +18,8 @@ const failed = (line) => ({ code: 1, lines: [`✗ ${line}`] });
18
18
  function decision({ kind, tools = [], reason = "" }) {
19
19
  if (kind === "judgment") {
20
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 } } };
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() } : {}) } };
22
23
  }
23
24
  if (kind === "out-of-scope") {
24
25
  if (!reason.trim()) return { error: "an out-of-scope rule says why no tool call breaks it: --reason <text>" };
@@ -21,8 +21,21 @@ const TASK = [
21
21
  " examples picked so that a weak condition passes, is a defect. So is a condition so wide that it denies honest calls.",
22
22
  "- kind judgment or out-of-scope: nothing is blocked on these. If a condition over a tool name and its arguments could",
23
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.",
24
+ "Any rule may give a `reason` for how it is compiled: weigh it, do not take it on trust.",
25
+ "Know what a condition can see before you ask for one (KJC-TSK-0973): the tool name and the arguments of ONE call, with",
26
+ "the operators equals, in, matches, exists, gt, lt. It cannot read a file a command names (a commit message passed with -F,",
27
+ "a body file), earlier calls, or the state of the repo. A rule whose breach is only visible there cannot be deterministic,",
28
+ "and judgment is its honest kind. A condition that would deny honest calls (a word that is also ordinary text, a method",
29
+ "name, a comment) is a defect too: do not ask for one, and do not ask for more than the text forbids.",
30
+ "A deterministic rule that enforces every part of its text a condition CAN see is not weak for leaving out the part no",
31
+ "condition can see: block it only if the visible part is compiled narrower than it could be.",
32
+ "What blocks is MATERIAL weakness: a form of the forbidden call an agent would plausibly write in ordinary work is let",
33
+ "through, or a call an agent would plausibly make in honest work is denied. A pattern over shell text is never complete,",
34
+ "and these gates exist for a rule forgotten or bent in passing; an agent contorting a command to dodge them is stopped at",
35
+ "the checkpoints it does not reach (git hooks, CI), not here. So an exotic form (a command inside a control structure, a",
36
+ "rare global option, unusual casing) is a non-blocking suggestion, not a blocking issue.",
37
+ "Report as a BLOCKING issue every rule materially weaker than its text, naming its id (R-...) and saying what would",
38
+ "enforce it. Approve when none is.",
26
39
  ].join("\n");
27
40
 
28
41
  /**
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The contract commit (KJC-TSK-0857, KJC-BUG-0273). What kj generates for a
3
+ * project (playbook, skills, settings, workflows, gate marker, hooks) the whole
4
+ * team must inherit by cloning, so it goes into git. Nobody wrote it, and an
5
+ * agent's own PR-size rules forbid a 1200-line commit, so kj makes the commit
6
+ * itself: only the files it just generated, never what was already dirty, and
7
+ * never on the base branch once the repo has history. This is NOT the supervisor
8
+ * seal (ADR 0009), which stays a human act.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+
12
+ /** What kj generates and the whole team must inherit by cloning (prefixes). */
13
+ export const CONTRACT_PATHS = [
14
+ ".gitignore",
15
+ ".karajan/hooks/",
16
+ ".karajan/review-gate",
17
+ ".karajan/adrs/",
18
+ ".karajan/policy.yml",
19
+ ".claude/",
20
+ ".github/workflows/kj-",
21
+ "CLAUDE.md",
22
+ "AGENTS.md",
23
+ "GEMINI.md",
24
+ // KJC-TSK-0879: in a Rulesync repo kj's rules live in .rulesync/rules/karajan.md.
25
+ ".rulesync/",
26
+ ];
27
+
28
+ const FRESH_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
29
+ const REGEN_MESSAGE = "chore(kj): el contrato del método, generado por kj";
30
+
31
+ const runner = (projectDir, env) => (args) => execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
32
+ const isContract = (file) => CONTRACT_PATHS.some((p) => (p.endsWith("/") || p.endsWith("-") ? file.startsWith(p) : file === p));
33
+
34
+ /** The contract files git sees as changed, with their porcelain code (`??` untracked). */
35
+ function contractStatus(git) {
36
+ try {
37
+ const lines = git(["status", "--porcelain", "--untracked-files=all"]).split("\n").filter(Boolean);
38
+ return lines.map((line) => [line.slice(3).trim().split(" -> ").at(-1), line.slice(0, 2)]).filter(([file]) => isContract(file));
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ /**
45
+ * The contract files that differ from HEAD (or are untracked), one by one.
46
+ * @returns {Set<string>} empty when git cannot answer
47
+ */
48
+ export function contractChanges(projectDir, git = runner(projectDir, process.env)) {
49
+ return new Set(contractStatus(git).map(([file]) => file));
50
+ }
51
+
52
+ const hasCommits = (git) => { try { git(["rev-parse", "--verify", "HEAD"]); return true; } catch { return false; } };
53
+ const branchOf = (git) => { try { return git(["symbolic-ref", "--short", "HEAD"]).trim(); } catch { return ""; } };
54
+
55
+ /**
56
+ * @param {{projectDir: string, before?: Set<string>, baseBranch?: string, env?: object}} opts
57
+ * `before`: the contract files already dirty BEFORE kj generated anything,
58
+ * tracked or not. They may carry the person's words, so they stay out.
59
+ * @returns {{committed: boolean, files?: string[], reason?: string}}
60
+ */
61
+ export function commitContract({ projectDir, before = new Set(), baseBranch = "main", env = process.env }) {
62
+ const git = runner(projectDir, env);
63
+ const files = contractStatus(git).map(([file]) => file).filter((file) => !before.has(file)).sort();
64
+ if (files.length === 0) return { committed: false, reason: "nothing of the contract to commit" };
65
+ const history = hasCommits(git);
66
+ if (history && branchOf(git) === baseBranch) {
67
+ // kj never commits on the base branch, and will not switch the person's branch
68
+ // for them: the files are named so the commit can be made where it belongs.
69
+ return { committed: false, files, reason: `on the base branch '${baseBranch}', where kj never commits: create a branch and commit these generated files there (git checkout -b chore/kj-contract && git add -- ${files.join(" ")} && git commit -m "chore(kj): el contrato del método")` };
70
+ }
71
+ try {
72
+ git(["add", "--", ...files]);
73
+ // --only: these paths and nothing else. What the person had staged stays staged.
74
+ git(["commit", "--only", "-m", history ? REGEN_MESSAGE : FRESH_MESSAGE, "--", ...files]);
75
+ } catch (err) {
76
+ return { committed: false, files, reason: `git could not commit the contract: ${String(err.stderr || err.message).trim().split("\n")[0]}` };
77
+ }
78
+ return { committed: true, files };
79
+ }
@@ -10,7 +10,12 @@
10
10
  */
11
11
  const GATE = "deterministic";
12
12
 
13
- const compiled = (rule) => (rule.kind === "out-of-scope" ? `reason: ${rule.reason}` : `when: ${JSON.stringify(rule.when)}`);
13
+ const compiled = (rule) => {
14
+ if (rule.kind === "out-of-scope") return `reason: ${rule.reason}`;
15
+ // KJC-TSK-0973: why a judgment rule takes no condition is read next to it.
16
+ const why = rule.reason ? " (" + rule.reason + ")" : "";
17
+ return `when: ${JSON.stringify(rule.when)}${why}`;
18
+ };
14
19
 
15
20
  /** How a proposed rule weakens or changes the approved one, or "" when it does not. */
16
21
  function weakening(rule, approved) {
@@ -79,7 +79,10 @@ function ruleErrors(rule) {
79
79
  const notText = ["source", "text", "message"].filter((k) => k in rule && typeof rule[k] !== "string");
80
80
  if (notText.length) errors.push(`${notText.join(", ")} must be text`);
81
81
  if (rule.kind === OUT_OF_SCOPE) return [...errors, ...outOfScopeErrors(rule)];
82
- if ("reason" in rule) errors.push(`reason belongs to ${OUT_OF_SCOPE} rules only`);
82
+ // KJC-TSK-0973: any rule may say why it is compiled as it is. A judgment rule,
83
+ // why it takes no condition; a deterministic one, where its condition stops
84
+ // (what breaks the rest is not in the arguments of one call).
85
+ if ("reason" in rule && (typeof rule.reason !== "string" || !rule.reason.trim())) errors.push("reason is text");
83
86
  // `examples` are tool calls: the command that runs them checks their shape (kj rules test).
84
87
  const tools = [rule.when?.tool].flat();
85
88
  if (tools.length === 0 || tools.some((t) => typeof t !== "string" || !t)) errors.push("when.tool names the tool: a glob, or a list where every item is one");