karajan-code 4.59.1 → 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 +3 -2
- package/src/checks/rulesync.js +65 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +14 -0
- package/src/cli/register-pipeline.js +2 -0
- package/src/commands/doctor.js +3 -1
- package/src/commands/init.js +11 -0
- package/src/environment/playbook.js +13 -7
- package/src/harden/guidelines-engine.js +19 -9
- package/src/utils/rulesync-adopt.js +96 -0
- package/src/utils/rulesync-ledger.js +102 -0
- package/src/utils/rulesync.js +110 -6
- package/src/utils/update-check.js +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "4.
|
|
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"] },
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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) => {
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
81
|
+
const { content: _content, ...summary } = res;
|
|
82
|
+
results.push({ file, ...summary });
|
|
73
83
|
}
|
|
74
84
|
return { dryRun, guidelines: results };
|
|
75
85
|
}
|
|
@@ -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
|
+
}
|
package/src/utils/rulesync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|