karajan-code 4.59.0 → 4.60.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.59.0",
3
+ "version": "4.60.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",
@@ -39,7 +39,8 @@
39
39
  "packages/core",
40
40
  "packages/hu-board",
41
41
  "packages/governance",
42
- "packages/console"
42
+ "packages/console",
43
+ "packages/atril"
43
44
  ],
44
45
  "imports": {
45
46
  "#utils/*": "./src/utils/*",
@@ -0,0 +1,65 @@
1
+ /**
2
+ * HU-0005 (ADR 0022, decision 6): the rulesync range kj was tested with, and
3
+ * the doctor check that says when a project's rulesync falls outside it.
4
+ * kj compiles its rules through rulesync (src/utils/rulesync.js), so a
5
+ * version it never ran against is said, never assumed to work.
6
+ */
7
+ import { runCommand } from "../utils/process.js";
8
+ import { detectRulesync, rulesyncCommand } from "../utils/rulesync.js";
9
+ import { isNewerVersion } from "../utils/update-check.js";
10
+ import { STRATEGY } from "./types.js";
11
+
12
+ /** Tested with rulesync 16.14.0; any later 16.x release is accepted, 17 is not. */
13
+ export const RULESYNC_TESTED_RANGE = { min: "16.14.0", nextMajor: 17 };
14
+
15
+ const EXPECTED = `>=${RULESYNC_TESTED_RANGE.min} <${RULESYNC_TESTED_RANGE.nextMajor}`;
16
+ const FIX = `install rulesync@"${EXPECTED}" as a dev dependency`;
17
+ // A cold `npx --no-install` can exceed the runner's 5 s default.
18
+ const VERSION_TIMEOUT_MS = 10_000;
19
+ const VERSION_RE = /(\d+)\.(\d+)\.(\d+)(-\S+)?/;
20
+
21
+ const parse = (text) => {
22
+ const m = VERSION_RE.exec(String(text));
23
+ return m ? { parts: m.slice(1, 4).map(Number), prerelease: Boolean(m[4]), text: m[0] } : null;
24
+ };
25
+
26
+ /** True for a release at least the tested minimum and below the next major; prereleases are untested. */
27
+ export function inTestedRange(version) {
28
+ const v = parse(version);
29
+ if (!v || v.prerelease || v.parts[0] >= RULESYNC_TESTED_RANGE.nextMajor) return false;
30
+ return !isNewerVersion(RULESYNC_TESTED_RANGE.min, v.text);
31
+ }
32
+
33
+ async function readVersion(projectDir, run) {
34
+ const [cmd, args] = rulesyncCommand(projectDir, ["--version"]);
35
+ try {
36
+ const res = await run(cmd, args, { cwd: projectDir, timeout: VERSION_TIMEOUT_MS });
37
+ if (res.exitCode !== 0) return { error: res.stderr?.trim() || `exit code ${res.exitCode}` };
38
+ const version = parse(res.stdout);
39
+ return version ? { version: version.text } : { error: `unexpected output "${res.stdout.trim()}"` };
40
+ } catch (err) {
41
+ return { error: err.message };
42
+ }
43
+ }
44
+
45
+ const warn = (detail) => ({ ok: false, severity: "warn", detail, fix: FIX });
46
+
47
+ function createRulesyncCheck({ projectDir, run }) {
48
+ return {
49
+ name: "rulesync",
50
+ label: "Rulesync",
51
+ strategy: STRATEGY.MANUAL,
52
+ detectTimeoutMs: VERSION_TIMEOUT_MS + 5_000,
53
+ async detect() {
54
+ if (!detectRulesync(projectDir)) return { ok: true, severity: "info", detail: "not used in this project" };
55
+ const { version, error } = await readVersion(projectDir, run);
56
+ if (error) return warn(`the project uses rulesync but its version could not be read (${error}); tested range ${EXPECTED}`);
57
+ if (!inTestedRange(version)) return warn(`rulesync ${version} is outside the range kj was tested with (${EXPECTED})`);
58
+ return { ok: true, severity: "info", detail: `rulesync ${version} (tested range ${EXPECTED})` };
59
+ },
60
+ };
61
+ }
62
+
63
+ export function getRulesyncChecks({ projectDir = process.cwd(), run = runCommand } = {}) {
64
+ return [createRulesyncCheck({ projectDir, run })];
65
+ }
@@ -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", "rules", "claims", "steward", "pr-size"] },
37
+ { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "rules", "rulesync", "claims", "steward", "pr-size"] },
38
38
  { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity", "security", "handoff"] },
39
39
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
40
40
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
@@ -35,6 +35,7 @@ import { reportIssueCommand } from "../commands/report-issue.js";
35
35
  import { huCommand, HU_STATUSES } from "../commands/hu.js";
36
36
  import { worktreeCommand } from "../commands/worktree.js";
37
37
  import { addAdr, listAdrs } from "../environment/adr.js";
38
+ import { settleRulesyncEntry } from "../utils/rulesync.js";
38
39
  import { formatAdvancedIndex } from "../commands/advanced.js";
39
40
  import { policyAddCommand } from "../policy/add.js";
40
41
  import { withConfig } from "./_shared.js";
@@ -376,6 +377,19 @@ export function registerMeta(program, { pkgVersion }) {
376
377
  });
377
378
  });
378
379
 
380
+ // HU-0003 (ADR 0022): settle a .rulesync/ ledger entry edited by hand.
381
+ const rulesyncCmd = program.command("rulesync").description("The .rulesync/ ownership ledger (ADR 0022)");
382
+ for (const [action, what] of [["adopt", "Take the entry's current content as kj's"], ["release", "Hand kj's entry to you: kj stops writing it, and later installs stop on it until you adopt it"]]) {
383
+ rulesyncCmd.command(`${action} <key>`).description(`${what} (key as in .rulesync/.owners.json)`)
384
+ .option("--json", "Machine-readable output")
385
+ .action(async (key, flags) => {
386
+ await withConfig(pkgVersion, "rulesync", flags, async ({ config }) => {
387
+ const res = settleRulesyncEntry({ projectDir: config?.projectDir || process.cwd(), key, action });
388
+ console.log(flags.json ? JSON.stringify(res) : `✓ ${key} is now ${res.owner === "kj" ? "kj's" : "yours"}`);
389
+ });
390
+ });
391
+ }
392
+
379
393
  // MDR-A2 (KJC-TSK-0942, ADR 0016): the rules of the governing MD files, each with an id.
380
394
  const rules = program.command("rules").description("Rules written in the MD files that govern a session (ADR 0016)");
381
395
  rules.command("list")
@@ -37,6 +37,8 @@ export function registerPipeline(program, { pkgVersion }) {
37
37
  .option("--no-squeezr", "Skip the Squeezr auto-install (context compression). Karajan still runs but burns more tokens")
38
38
  .option("--no-qmd", "Skip the QMD auto-install + collection registration (semantic wiki over docs/, .reviews/ and plans/)")
39
39
  .option("--no-harden", "Skip the quality harness (git hooks, lint/commit config, CI gates, agent guidelines)")
40
+ .option("--rulesync", "Adopt rulesync without asking (rulesync init + import; kj's rules move to .rulesync/rules/karajan.md)")
41
+ .option("--no-rulesync", "Do not adopt rulesync; kj keeps writing its rules into CLAUDE.md/AGENTS.md")
40
42
  .option("--no-sonar", "Do not start the SonarQube container (Docker). The review gate still needs it: start it later with 'kj sonar start'")
41
43
  .option("--json", "Emit a machine-readable summary of what init did (AB-B: for host agents)")
42
44
  .action(async (flags) => {
@@ -28,6 +28,7 @@ import { getSqueezrChecks } from "../checks/squeezr.js";
28
28
  import { getAiTrashChecks } from "../checks/ai-trash.js";
29
29
  import { getQmdChecks } from "../checks/qmd.js";
30
30
  import { getRagHooksChecks } from "../checks/rag-hooks.js";
31
+ import { getRulesyncChecks } from "../checks/rulesync.js";
31
32
  import { getNodeChecks } from "../checks/node.js";
32
33
  import { getNativeBuildChecks } from "../checks/native-build.js";
33
34
  import { getPortChecks } from "../checks/ports.js";
@@ -55,7 +56,7 @@ import { getMethodChecks } from "../checks/method.js";
55
56
  function buildChecks(config, { projectOnly = false } = {}) {
56
57
  const projectDir = config?.projectDir || process.cwd();
57
58
  if (projectOnly) {
58
- return getProjectChecks({ projectDir });
59
+ return [...getProjectChecks({ projectDir }), ...getRulesyncChecks({ projectDir })];
59
60
  }
60
61
  return [
61
62
  ...getSystemChecks(),
@@ -81,6 +82,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
81
82
  ...getMethodChecks(),
82
83
  ...getQmdChecks(),
83
84
  ...getRagHooksChecks({ projectDir }),
85
+ ...getRulesyncChecks({ projectDir }),
84
86
  ...getProjectChecks({ projectDir }),
85
87
  ];
86
88
  }
@@ -12,6 +12,7 @@ import { getKarajanHome } from "../utils/paths.js";
12
12
  import { getTemplatesRoot } from "../utils/templates-root.js";
13
13
  import { detectAvailableAgents } from "../utils/agent-detect.js";
14
14
  import { createWizard, isTTY } from "../utils/wizard.js";
15
+ import { offerRulesyncAdoption } from "../utils/rulesync-adopt.js";
15
16
  import { runCommand } from "../utils/process.js";
16
17
  import { getInstallCommand } from "../utils/os-detect.js";
17
18
  import { detectOsLocale, SUPPORTED_LANGUAGES } from "../utils/locale.js";
@@ -822,6 +823,12 @@ export function ensureGitRepo({ projectDir, logger, gitFn = null }) {
822
823
  }
823
824
  }
824
825
 
826
+ /** One yes/no question through a wizard of its own, closed after the answer. */
827
+ const askOnce = (question) => {
828
+ const wizard = createWizard();
829
+ return wizard.confirm(question, false).finally(() => wizard.close());
830
+ };
831
+
825
832
  export async function initCommand({ logger, flags = {} }) {
826
833
  // AB-B (KJC-TSK-0656): with --json, stdout is a machine contract — every
827
834
  // human log moves to stderr so the only stdout line is the summary object.
@@ -982,6 +989,10 @@ export async function initCommand({ logger, flags = {} }) {
982
989
  }
983
990
  await scaffoldCiGateway(config, flags, logger);
984
991
 
992
+ // HU-0004 (ADR 0022): before the harness writes kj's rules, so they land in
993
+ // rulesync's source when the project adopts it.
994
+ await offerRulesyncAdoption({ projectDir: process.cwd(), flags, interactive, logger, confirm: askOnce });
995
+
985
996
  // Install the quality harness (git hooks, lint/format/commit config, CI
986
997
  // quality gates, agent guidelines) through the SAME engine as `kj harden`,
987
998
  // so init and harden never drift. Opt-out: --no-harden. Needs a git repo.
@@ -12,7 +12,7 @@
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { upsertManagedBlock } from "../utils/managed-markers.js";
15
- import { detectRulesync, RULESYNC_RULE, rulesyncRuleSeed } from "../utils/rulesync.js";
15
+ import { detectRulesync, RULESYNC_RULE, rulesyncRuleSeed, writeRulesyncBlock } from "../utils/rulesync.js";
16
16
  import { panelLine } from "./panel.js";
17
17
 
18
18
  // AB-A (KJC-TSK-0650): any agent can be the brain. AGENTS.md is the
@@ -126,15 +126,21 @@ export async function installPlaybook({ projectDir, target = "all", version = "1
126
126
  const rulesync = detectRulesync(projectDir);
127
127
  for (const file of rulesync ? [RULESYNC_RULE] : TARGET_FILES[target]) {
128
128
  const fullPath = path.join(projectDir, file);
129
- let source = rulesync ? rulesyncRuleSeed() : "";
130
- try { source = await fs.readFile(fullPath, "utf8"); } catch { /* new file */ }
131
- const { content, action } = upsertManagedBlock({
129
+ const render = (source) => upsertManagedBlock({
132
130
  source, blockId: "playbook", version, body: renderPlaybook({ stateBackend, boardName, config }), style: "html",
133
131
  note: "do not edit: regenerated by kj env install",
134
132
  });
135
- if (action !== "unchanged") {
136
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
137
- await fs.writeFile(fullPath, content);
133
+ if (rulesync) {
134
+ // HU-0002: read, checked and written under the ownership ledger's lock.
135
+ writeRulesyncBlock({ projectDir, blockId: "playbook", render: (previous) => render(previous ?? rulesyncRuleSeed()) });
136
+ } else {
137
+ let source = "";
138
+ try { source = await fs.readFile(fullPath, "utf8"); } catch { /* new file */ }
139
+ const { content, action } = render(source);
140
+ if (action !== "unchanged") {
141
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
142
+ await fs.writeFile(fullPath, content);
143
+ }
138
144
  }
139
145
  files.push(file);
140
146
  }
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
 
11
11
  import { upsertManagedBlock } from "../utils/managed-markers.js";
12
- import { detectRulesync, RULESYNC_RULE, rulesyncRuleSeed } from "../utils/rulesync.js";
12
+ import { detectRulesync, RULESYNC_RULE, rulesyncRuleSeed, writeRulesyncBlock } from "../utils/rulesync.js";
13
13
  import { guidelinesBody } from "./guidelines-templates.js";
14
14
 
15
15
  const BLOCK_VERSION = 1;
@@ -45,10 +45,7 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false,
45
45
  // KJC-TSK-0879: with Rulesync, its source is the file of truth; writing into
46
46
  // CLAUDE.md/AGENTS.md would be erased by the next `rulesync generate`.
47
47
  const rulesync = detectRulesync(projectDir);
48
- for (const file of rulesync ? [RULESYNC_RULE] : TARGETS) {
49
- const target = join(projectDir, file);
50
- const seed = rulesync ? rulesyncRuleSeed() : "";
51
- const raw = existsSync(target) ? readFileSync(target, "utf8") : seed;
48
+ const render = (raw) => {
52
49
  const { content: source, migrated } = stripDevHooksBlock(raw);
53
50
  const { content, action } = upsertManagedBlock({
54
51
  source,
@@ -65,11 +62,24 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false,
65
62
  // KJC-BUG-0232: decir CUALES se van, o el aviso obliga a salir a git diff.
66
63
  const kept = new Set(nonEmpty(content));
67
64
  const removed = shrank ? nonEmpty(raw).filter((l) => !kept.has(l) && !l.includes("kj:managed:")) : [];
68
- if (!dryRun && (migrated || action !== "unchanged")) {
69
- mkdirSync(dirname(target), { recursive: true });
70
- writeFileSync(target, content);
65
+ return { content, action: migrated ? "migrated" : action, ...(shrank ? { shrank: true, removed } : {}) };
66
+ };
67
+ for (const file of rulesync ? [RULESYNC_RULE] : TARGETS) {
68
+ const target = join(projectDir, file);
69
+ const seed = rulesync ? rulesyncRuleSeed() : "";
70
+ let res;
71
+ if (rulesync && !dryRun) {
72
+ // HU-0002: read, checked and written under the ownership ledger's lock.
73
+ res = writeRulesyncBlock({ projectDir, blockId: "guidelines", render: (previous) => render(previous ?? seed) });
74
+ } else {
75
+ res = render(existsSync(target) ? readFileSync(target, "utf8") : seed);
76
+ if (!dryRun && res.action !== "unchanged") {
77
+ mkdirSync(dirname(target), { recursive: true });
78
+ writeFileSync(target, res.content);
79
+ }
71
80
  }
72
- results.push({ file, action: migrated ? "migrated" : action, ...(shrank ? { shrank: true, removed } : {}) });
81
+ const { content: _content, ...summary } = res;
82
+ results.push({ file, ...summary });
73
83
  }
74
84
  return { dryRun, guidelines: results };
75
85
  }
@@ -18,11 +18,14 @@ const baseOf = (range) => {
18
18
  /** The repo's allow list at the base of the range; empty when the base has no such file. */
19
19
  export async function repoPrivacyAllow({ projectDir, range, run = runCommand }) {
20
20
  const base = baseOf(range);
21
- const res = await run("git", ["-C", projectDir, "show", `${base}:${REPO_PRIVACY}`]);
22
- if (res.exitCode !== 0) {
23
- if (/does not exist|exists on disk, but not in/.test(res.stderr ?? "")) return [];
24
- throw new Error(`git show ${base}:${REPO_PRIVACY} failed: ${(res.stderr || "").trim()}`);
21
+ // KJC-BUG-0387: exit codes, never git's messages, which follow the user's language.
22
+ const git = (...args) => run("git", ["-C", projectDir, ...args]);
23
+ if ((await git("rev-parse", "--verify", "--quiet", `${base}^{commit}`)).exitCode !== 0) {
24
+ throw new Error(`cannot read the privacy allow list: the base ${base} is not a commit here`);
25
25
  }
26
+ if ((await git("cat-file", "-e", `${base}:${REPO_PRIVACY}`)).exitCode !== 0) return [];
27
+ const res = await git("show", `${base}:${REPO_PRIVACY}`);
28
+ if (res.exitCode !== 0) throw new Error(`git show ${base}:${REPO_PRIVACY} failed: ${(res.stderr || "").trim()}`);
26
29
  let raw;
27
30
  try {
28
31
  raw = parseYaml(res.stdout) || {};
@@ -0,0 +1,96 @@
1
+ // HU-0004 (ADR 0022, decision 5): kj init offers rulesync and asks. kj's blocks
2
+ // leave the agent files before `rulesync import`, so the import never repeats them.
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { findManagedBlock } from "./managed-markers.js";
6
+ import { ADOPTION_PENDING, defaultRun, detectRulesync, RULESYNC_RULE, rulesyncCommand, rulesyncRuleSeed, writeRulesyncBlock } from "./rulesync.js";
7
+
8
+ // Each agent file kj writes into, with the rulesync target that imports it.
9
+ const AGENT_FILES = [["CLAUDE.md", "claudecode"], ["AGENTS.md", "agentsmd"]];
10
+ const KJ_BLOCKS = ["playbook", "guidelines"];
11
+ const INSTALL = "npm install --save-dev rulesync";
12
+ /** Run one adoption step; on failure, stop naming it and what to run by hand. */
13
+ const step = (name, byHand, action) => {
14
+ try {
15
+ action();
16
+ } catch (error) {
17
+ throw new Error(`kj: rulesync adoption stopped at "${name}": ${error.message}\nRun by hand: ${byHand}`, { cause: error });
18
+ }
19
+ };
20
+
21
+ /** Split kj's block out of `text`: its lines, and the text without them. */
22
+ const takeBlock = (text, blockId) => {
23
+ const block = findManagedBlock(text, blockId);
24
+ if (!block.found) return { block: null, rest: text };
25
+ const lines = text.split("\n");
26
+ return {
27
+ block: lines.slice(block.startLine, block.endLine + 1).join("\n"),
28
+ rest: [...lines.slice(0, block.startLine), ...lines.slice(block.endLine + 1)].join("\n"),
29
+ };
30
+ };
31
+
32
+ /** Move kj's blocks from the agent files into its ledger-owned rule. */
33
+ function moveKjBlocks(projectDir) {
34
+ const paths = AGENT_FILES.map(([file]) => join(projectDir, file)).filter((path) => existsSync(path));
35
+ const texts = new Map(paths.map((path) => [path, readFileSync(path, "utf8")]));
36
+ for (const blockId of KJ_BLOCKS) {
37
+ let moved = null;
38
+ for (const [path, text] of texts) {
39
+ const { block, rest } = takeBlock(text, blockId);
40
+ moved ??= block; // first copy wins: harden re-renders the block anyway
41
+ texts.set(path, rest);
42
+ }
43
+ if (moved === null) continue;
44
+ writeRulesyncBlock({
45
+ projectDir, blockId,
46
+ // A resumed adoption may find the block already here: replace it, never repeat it.
47
+ render: (previous) => ({ content: `${takeBlock(previous ?? rulesyncRuleSeed(), blockId).rest.replace(/\n*$/, "\n")}${moved}\n` }),
48
+ });
49
+ }
50
+ for (const [path, text] of texts) writeFileSync(path, text);
51
+ }
52
+
53
+ /** Adopt rulesync: init, kj's blocks to its rule, then import what the agent files keep. */
54
+ function adoptRulesync({ projectDir, logger, run }) {
55
+ const exec = (args) => run(...rulesyncCommand(projectDir, args), projectDir);
56
+ try {
57
+ exec(["--version"]);
58
+ } catch {
59
+ logger.warn(`kj: rulesync is not installed, so it was not adopted. Install it with: ${INSTALL}, then run kj init again`);
60
+ return { adopted: false, reason: "not-installed" };
61
+ }
62
+ const execStep = (args) => {
63
+ const [cmd, fullArgs] = rulesyncCommand(projectDir, args);
64
+ step(`rulesync ${args.join(" ")}`, `${[cmd, ...fullArgs].join(" ")}, then kj init to finish it`, () => run(cmd, fullArgs, projectDir));
65
+ };
66
+ mkdirSync(join(projectDir, ".rulesync"), { recursive: true });
67
+ writeFileSync(join(projectDir, ADOPTION_PENDING), "kj init: rulesync adoption in progress\n");
68
+ execStep(["init"]);
69
+ step(`move kj's blocks into ${RULESYNC_RULE}`, `fix ${RULESYNC_RULE} or kj's blocks in CLAUDE.md/AGENTS.md, then kj init`, () => moveKjBlocks(projectDir));
70
+ for (const [file, target] of AGENT_FILES) {
71
+ if (existsSync(join(projectDir, file))) execStep(["import", "--targets", target]);
72
+ }
73
+ rmSync(join(projectDir, ADOPTION_PENDING));
74
+ logger.info(`kj: rulesync adopted; kj's rules now live in ${RULESYNC_RULE}`);
75
+ return { adopted: true };
76
+ }
77
+
78
+ /**
79
+ * Ask whether to adopt rulesync. `flags.rulesync` (--rulesync / --no-rulesync)
80
+ * answers without asking; with no TTY and no flag, nothing is adopted.
81
+ */
82
+ export async function offerRulesyncAdoption({ projectDir, flags = {}, interactive, confirm, logger = console, run = defaultRun }) {
83
+ if (detectRulesync(projectDir)) return { adopted: false, reason: "already" };
84
+ const halfDone = existsSync(join(projectDir, ADOPTION_PENDING));
85
+ const verb = halfDone ? "finish the half-done adoption" : "adopt it";
86
+ if (flags.rulesync === undefined && !interactive) {
87
+ logger.info(`kj: rulesync not adopted (nobody to ask); pass --rulesync to ${verb}, or --no-rulesync to keep the agent files`);
88
+ return { adopted: false, reason: "no-answer" };
89
+ }
90
+ const question = halfDone
91
+ ? "Finish adopting rulesync? A previous kj init stopped halfway (rulesync init + import)"
92
+ : "Adopt rulesync? One source in .rulesync/ for every agent's rules (rulesync init + import)";
93
+ const answer = flags.rulesync ?? await confirm(question);
94
+ if (answer !== true) return { adopted: false, reason: "declined" };
95
+ return adoptRulesync({ projectDir, logger, run });
96
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * HU-0001 (ADR 0022, decision 3) — the ownership ledger of `.rulesync/`. The
3
+ * user, kj and atril share one rulesync source; each owns what it wrote (a
4
+ * per-file feature keyed by its path relative to `.rulesync/`, e.g.
5
+ * `rules/karajan.md`, or `rules/karajan.md#<block>` for a writer's managed
6
+ * block; an entry of hooks/mcp/permissions by key), and the ledger is the
7
+ * single source of their hashes. No entry means the user's, unless a writer
8
+ * finds its marker there (it claims it, once); an entry released to the user
9
+ * (HU-0003) is owned by "user", so no writer claims it again. The
10
+ * format is a contract atril implements in its own package: hashed entries are
11
+ * serialized canonically (JSON, keys sorted) by every writer.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { writeJsonAtomicSync } from "./atomic-write.js";
17
+
18
+ export const LEDGER_FILE = join(".rulesync", ".owners.json");
19
+ export const LOCK_FILE = join(".rulesync", ".owners.lock");
20
+ export const LEDGER_VERSION = 1;
21
+ export const USER_OWNER = "user";
22
+ const HASH_RE = /^sha256:[0-9a-f]{64}$/;
23
+
24
+ export const mcpKey = (server) => `mcp:${server}`;
25
+ export const permissionKey = (rule) => `permissions:${rule}`;
26
+ export function hookKey(event, id) {
27
+ if (event.includes(":")) throw new Error(`hook event "${event}" contains ":", which would make its ledger key ambiguous`);
28
+ return `hooks:${event}:${id}`;
29
+ }
30
+
31
+ export const hashContent = (content) => `sha256:${createHash("sha256").update(content).digest("hex")}`;
32
+
33
+ function validate(file, ledger) {
34
+ if (ledger?.version !== LEDGER_VERSION) {
35
+ throw new Error(`${file}: version ${ledger?.version} is not supported (expected ${LEDGER_VERSION})`);
36
+ }
37
+ if (typeof ledger.entries !== "object" || ledger.entries === null || Array.isArray(ledger.entries)) {
38
+ throw new Error(`${file}: entries must be an object of key → { owner, hash }`);
39
+ }
40
+ for (const [key, entry] of Object.entries(ledger.entries)) {
41
+ if (typeof entry?.owner !== "string" || !entry.owner || typeof entry.hash !== "string" || !HASH_RE.test(entry.hash)) {
42
+ throw new Error(`${file}: entry "${key}" needs a non-empty owner and a sha256:<hex> hash`);
43
+ }
44
+ }
45
+ }
46
+
47
+ /** The ledger, or an empty one when absent. A broken, malformed or unknown one fails loudly. */
48
+ export function readLedger(projectDir) {
49
+ const file = join(projectDir, LEDGER_FILE);
50
+ if (!existsSync(file)) return { version: LEDGER_VERSION, entries: {} };
51
+ let ledger;
52
+ try { ledger = JSON.parse(readFileSync(file, "utf8")); } catch (err) {
53
+ throw new Error(`${file}: not valid JSON (${err.message})`, { cause: err });
54
+ }
55
+ validate(file, ledger);
56
+ return ledger;
57
+ }
58
+
59
+ function takeLock(lock, owner) {
60
+ try {
61
+ writeFileSync(lock, `${owner} ${process.pid}`, { flag: "wx" });
62
+ } catch (err) {
63
+ if (err.code === "ENOENT") throw new Error(`${dirname(lock)}: .rulesync/ is missing (run rulesync init)`, { cause: err });
64
+ if (err.code !== "EEXIST") throw err;
65
+ let holder = "unknown";
66
+ try { holder = readFileSync(lock, "utf8").trim() || holder; } catch { /* released meanwhile */ }
67
+ throw new Error(`${lock}: held by ${holder} — another writer is updating the ledger; retry when it ends `
68
+ + `(if no writer is running, delete ${lock})`, { cause: err });
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Read, change and write the ledger under its lock, so two writers never
74
+ * interleave. The write is atomic; a change that throws writes nothing. A
75
+ * lock left by a crashed writer is never stolen: the error says how to clear it.
76
+ * @param {string} projectDir
77
+ * @param {string} owner - who takes the lock, named to whoever finds it held
78
+ * @param {(ledger: object) => void} change - mutates the ledger in place
79
+ */
80
+ export function withLedger(projectDir, owner, change) {
81
+ if (typeof owner !== "string" || !owner) throw new Error("withLedger: owner must be a non-empty string");
82
+ const lock = join(projectDir, LOCK_FILE);
83
+ takeLock(lock, owner);
84
+ try {
85
+ const ledger = readLedger(projectDir);
86
+ change(ledger);
87
+ validate(join(projectDir, LEDGER_FILE), ledger);
88
+ writeJsonAtomicSync(join(projectDir, LEDGER_FILE), ledger);
89
+ return ledger;
90
+ } finally {
91
+ rmSync(lock, { force: true });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * "ok" when the content still matches its recorded hash, "edited" when it
97
+ * changed by hand (the writer must stop), "unowned" when no writer recorded it.
98
+ */
99
+ export function verifyEntry(ledger, key, content) {
100
+ if (!Object.hasOwn(ledger.entries, key)) return "unowned";
101
+ return ledger.entries[key].hash === hashContent(content) ? "ok" : "edited";
102
+ }
@@ -8,8 +8,10 @@
8
8
  * source instead, with the same managed markers.
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
- import { existsSync } from "node:fs";
12
- import { join } from "node:path";
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
+ import { dirname, join, relative, sep } from "node:path";
13
+ import { findManagedBlock } from "./managed-markers.js";
14
+ import { hashContent, USER_OWNER, verifyEntry, withLedger } from "./rulesync-ledger.js";
13
15
 
14
16
  export const RULESYNC_RULE = join(".rulesync", "rules", "karajan.md");
15
17
 
@@ -25,16 +27,28 @@ const FRONTMATTER = [
25
27
  "",
26
28
  ].join("\n");
27
29
 
28
- /** The project uses Rulesync when it has `.rulesync/` or `rulesync.jsonc`. */
30
+ // HU-0011: kj init writes this before adopting rulesync and removes it after the
31
+ // last import. While it exists the adoption is half done, so nothing in kj treats
32
+ // the project as a rulesync one. Local state: never commit it.
33
+ export const ADOPTION_PENDING = join(".rulesync", ".kj-adoption-pending");
34
+
35
+ /** The project uses Rulesync when it has `.rulesync/` or `rulesync.jsonc`, and no adoption is half done. */
29
36
  export function detectRulesync(projectDir) {
37
+ if (existsSync(join(projectDir, ADOPTION_PENDING))) return false;
30
38
  return existsSync(join(projectDir, ".rulesync")) || existsSync(join(projectDir, "rulesync.jsonc"));
31
39
  }
32
40
 
33
41
  /** What a brand-new karajan.md starts with, before kj's managed blocks. */
34
42
  export const rulesyncRuleSeed = () => FRONTMATTER;
35
43
 
44
+ /** The project's own rulesync binary, or npx without installing: kj never runs a package fetched on the fly. */
45
+ export function rulesyncCommand(projectDir, args) {
46
+ const local = join(projectDir, "node_modules", ".bin", "rulesync");
47
+ return existsSync(local) ? [local, args] : ["npx", ["--no-install", "rulesync", ...args]];
48
+ }
49
+
36
50
  const GENERATE_ARGS = ["generate", "--targets", "*", "--features", "*"];
37
- const defaultRun = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
51
+ export const defaultRun = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
38
52
 
39
53
  /**
40
54
  * Compile kj's rules out to every agent, only when the project declared
@@ -48,8 +62,7 @@ export function maybeRulesyncGenerate({ projectDir, config, logger = console, ru
48
62
  logger.info?.(`kj: its rules are in ${RULESYNC_RULE}; run rulesync generate to reach your agents, or declare rulesync.generate: true`);
49
63
  return { ran: false, reason: "not-opted-in" };
50
64
  }
51
- const local = join(projectDir, "node_modules", ".bin", "rulesync");
52
- const [cmd, args] = existsSync(local) ? [local, GENERATE_ARGS] : ["npx", ["--no-install", "rulesync", ...GENERATE_ARGS]];
65
+ const [cmd, args] = rulesyncCommand(projectDir, GENERATE_ARGS);
53
66
  try {
54
67
  run(cmd, args, projectDir);
55
68
  logger.info?.("kj: rulesync generate spread kj's rules to every agent");
@@ -59,3 +72,94 @@ export function maybeRulesyncGenerate({ projectDir, config, logger = console, ru
59
72
  return { ran: false, reason: "failed" };
60
73
  }
61
74
  }
75
+
76
+ // HU-0002 (ADR 0022, decision 3): kj owns its managed blocks of karajan.md,
77
+ // not the file — the team's own rules outside them stay free.
78
+ const blockText = (text, blockId) => {
79
+ const block = findManagedBlock(text, blockId);
80
+ return block.found ? text.split("\n").slice(block.startLine, block.endLine + 1).join("\n") : null;
81
+ };
82
+
83
+ /**
84
+ * Write one of kj's blocks of a rulesync rule through the ownership ledger.
85
+ * The file is read, checked and written under the ledger's lock: `render`
86
+ * gets its current text (null when absent) and returns `{ content, ... }`.
87
+ * A block kj wrote before the ledger existed is claimed once, and said; an
88
+ * owned block edited, broken or deleted by hand stops the write.
89
+ * @returns {object} what `render` returned, plus `claimed` when it was claimed.
90
+ */
91
+ export function writeRulesyncBlock({ projectDir, file = RULESYNC_RULE, blockId, render, logger = console }) {
92
+ // Ledger keys are a contract with atril: always posix.
93
+ const key = `${relative(".rulesync", file).split(sep).join("/")}#${blockId}`;
94
+ const target = join(projectDir, file);
95
+ let result;
96
+ withLedger(projectDir, "kj", (ledger) => {
97
+ const previous = existsSync(target) ? readFileSync(target, "utf8") : null;
98
+ const current = previous === null ? null : blockText(previous, blockId);
99
+ const owned = Object.hasOwn(ledger.entries, key);
100
+ const owner = ledger.entries[key]?.owner;
101
+ if (owned && owner !== "kj") {
102
+ throw new Error(`${file}: "${key}" belongs to ${owner}; kj will not touch it`
103
+ + (owner === USER_OWNER ? ` (kj rulesync adopt ${key} gives it back to kj)` : ""));
104
+ }
105
+ if (current === null ? owned : verifyEntry(ledger, key, current) === "edited") {
106
+ throw new Error(`${file}: kj's ${blockId} block was edited by hand; kj will not overwrite it. `
107
+ + `Restore kj's block as it was, run kj rulesync adopt ${key} to keep the edit as kj's, `
108
+ + `or kj rulesync release ${key} to make the block yours`);
109
+ }
110
+ const claimed = current !== null && !owned;
111
+ if (claimed) logger.info(`kj: claimed ${key}, written by kj before the ownership ledger existed`);
112
+ result = { ...render(previous), ...(claimed ? { claimed } : {}) };
113
+ const written = blockText(result.content, blockId);
114
+ if (written === null) throw new Error(`${file}: rendering kj's ${blockId} block left it out; nothing written`);
115
+ if (result.content !== previous) {
116
+ // Rule first, ledger second (withLedger writes it on return): a crash in
117
+ // between reads as "edited" next time — loud, never a silent overwrite.
118
+ mkdirSync(dirname(target), { recursive: true });
119
+ writeFileSync(`${target}.tmp`, result.content);
120
+ try {
121
+ renameSync(`${target}.tmp`, target);
122
+ } catch (err) {
123
+ rmSync(`${target}.tmp`, { force: true });
124
+ throw err;
125
+ }
126
+ }
127
+ ledger.entries[key] = { owner: "kj", hash: hashContent(written) };
128
+ });
129
+ return result;
130
+ }
131
+
132
+ // The current content of a rules file (`rules/x.md`) or of a managed block
133
+ // in one (`rules/x.md#<block>`), as its ledger entry hashes it.
134
+ function entryContent(projectDir, key) {
135
+ if (/^(mcp|permissions|hooks):/.test(key)) throw new Error(`"${key}": kj only adopts rules files and their blocks`);
136
+ const [path, blockId, ...rest] = key.split("#");
137
+ if (rest.length || path.startsWith("/") || path.split("/").includes("..")) throw new Error(`"${key}": not a key inside .rulesync/`);
138
+ const file = join(projectDir, ".rulesync", ...path.split("/"));
139
+ if (!existsSync(file)) throw new Error(`${file}: missing, nothing to adopt for "${key}"`);
140
+ const text = readFileSync(file, "utf8");
141
+ const content = blockId === undefined ? text : blockText(text, blockId);
142
+ if (content === null) throw new Error(`${file}: no complete ${blockId} block, nothing to adopt for "${key}"`);
143
+ return content;
144
+ }
145
+
146
+ /**
147
+ * HU-0003: settle a ledger entry. `adopt` records its current content as
148
+ * kj's (also taking back one released to the user); `release` hands kj's
149
+ * entry to the user, who then owns it: kj never claims it again.
150
+ * @param {{ projectDir: string, key: string, action: "adopt"|"release" }} args
151
+ * @returns {{ key: string, owner: string, hash: string }}
152
+ */
153
+ export function settleRulesyncEntry({ projectDir, key, action }) {
154
+ const from = { adopt: ["kj", USER_OWNER], release: ["kj"] }[action];
155
+ if (!from) throw new Error(`rulesync: unknown action "${action}" (adopt | release)`);
156
+ const { entries } = withLedger(projectDir, "kj", (ledger) => {
157
+ const owner = ledger.entries[key]?.owner;
158
+ if (!owner) throw new Error(`"${key}" is not in .rulesync/.owners.json: it has no owner to ${action} from`);
159
+ if (!from.includes(owner)) throw new Error(`"${key}" is owned by ${owner}; kj cannot ${action} it`);
160
+ ledger.entries[key] = action === "adopt"
161
+ ? { owner: "kj", hash: hashContent(entryContent(projectDir, key)) }
162
+ : { ...ledger.entries[key], owner: USER_OWNER };
163
+ });
164
+ return { key, ...entries[key] };
165
+ }
@@ -161,15 +161,22 @@ export async function checkForUpdate(currentVersion) {
161
161
 
162
162
  /**
163
163
  * Print update notice if available. Call at CLI startup, non-blocking.
164
+ * KJC-BUG-0389: on stderr. It prints after the command's output, and on stdout it
165
+ * trailed every --json document and the verdict the rules hook parses.
164
166
  */
165
167
  export async function printUpdateNotice(currentVersion) {
166
168
  const result = await checkForUpdate(currentVersion);
167
169
  if (result?.updateAvailable) {
168
170
  const channel = detectInstallChannel();
169
- console.log(`\n Update available: v${result.current} → v${result.latest}`);
170
- if (result.highlight) console.log(` What it brings: ${result.highlight}`);
171
- console.log(` ${updateInstruction({ channel })}`);
172
- console.log(" Agent: tell your user and ask before updating — never update on your own.\n");
171
+ const lines = [
172
+ "",
173
+ ` Update available: v${result.current} → v${result.latest}`,
174
+ ...(result.highlight ? [` What it brings: ${result.highlight}`] : []),
175
+ ` ${updateInstruction({ channel })}`,
176
+ " Agent: tell your user and ask before updating — never update on your own.",
177
+ "",
178
+ ];
179
+ process.stderr.write(`${lines.join("\n")}\n`);
173
180
  }
174
181
  }
175
182