@karajan-family/code 4.67.0 → 4.68.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/register-meta.js +3 -2
- package/src/cli.js +4 -0
- package/src/commands/harden.js +3 -2
- package/src/git/symlink-targets.js +57 -0
- package/src/harden/hook-commands.js +19 -1
- package/src/mcp/server.js +4 -0
- package/src/orchestrator/solomon-escalation.js +27 -1
- package/src/privacy/diff-scope.js +3 -0
- package/src/roles/solomon-role.js +6 -0
- package/src/utils/git-diff-format.js +40 -0
package/package.json
CHANGED
package/src/cli/register-meta.js
CHANGED
|
@@ -1119,7 +1119,8 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
1119
1119
|
.option("--no-config", "Skip lint/format/commit config files (hooks only)")
|
|
1120
1120
|
.option("--no-ci", "Skip CI quality workflows")
|
|
1121
1121
|
.option("--no-guidelines", "Skip AI-agent guideline files (AGENTS.md/CLAUDE.md)")
|
|
1122
|
-
.option("--
|
|
1122
|
+
.option("--sandbox", "Turn the host Bash sandbox on where it can start (off by default: it blocks gh, push and Sonar)")
|
|
1123
|
+
.option("--no-sandbox", "Leave the host Bash sandbox alone (the default; kept for existing scripts)")
|
|
1123
1124
|
.option("--mutation", "Also seed an opt-in nightly/manual mutation CI job (never a PR gate)")
|
|
1124
1125
|
.option("--only <dirs...>", "Harden only these language roots (e.g. frontend backend)")
|
|
1125
1126
|
.option("--exclude <globs...>", "Skip language roots matching these globs (e.g. wrappers)")
|
|
@@ -1137,7 +1138,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
1137
1138
|
config: flags.config !== false,
|
|
1138
1139
|
ci: flags.ci !== false,
|
|
1139
1140
|
guidelines: flags.guidelines !== false,
|
|
1140
|
-
sandbox: flags.sandbox
|
|
1141
|
+
sandbox: flags.sandbox === true,
|
|
1141
1142
|
mutation: Boolean(flags.mutation),
|
|
1142
1143
|
only: flags.only ?? [],
|
|
1143
1144
|
exclude: flags.exclude ?? [],
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,10 @@ import { registerStandby } from "./cli/register-standby.js";
|
|
|
14
14
|
import { printUpdateNotice } from "./utils/update-check.js";
|
|
15
15
|
import { printWelcomeScreen } from "./utils/welcome.js";
|
|
16
16
|
import { migrateKjToKarajan } from "./utils/home-migration.js";
|
|
17
|
+
import { pinGitDiffFormat } from "./utils/git-diff-format.js";
|
|
18
|
+
|
|
19
|
+
// KJC-BUG-0408: every git kj runs prints a/ and b/, whatever the user's gitconfig says.
|
|
20
|
+
pinGitDiffFormat();
|
|
17
21
|
|
|
18
22
|
const PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
19
23
|
const PKG_VERSION = JSON.parse(readFileSync(PKG_PATH, "utf8")).version;
|
package/src/commands/harden.js
CHANGED
|
@@ -92,7 +92,7 @@ export async function hardenCommand({
|
|
|
92
92
|
only = [],
|
|
93
93
|
exclude = [],
|
|
94
94
|
commitSupervisor = false,
|
|
95
|
-
sandbox =
|
|
95
|
+
sandbox = false,
|
|
96
96
|
kjVersion = null,
|
|
97
97
|
// KJC-BUG-0289: the contract files already dirty before anything was generated
|
|
98
98
|
// (kj init captures them before writing its own). What is dirty NOW is the person's.
|
|
@@ -162,7 +162,8 @@ export async function hardenCommand({
|
|
|
162
162
|
// sin sello, que es como una sesión se dejaba sin herramientas.
|
|
163
163
|
const sh = installSentinelHooks({ projectDir, logger, human: commitSupervisor });
|
|
164
164
|
result.sentinelHooks = sh.wired ? "wired" : "script-only";
|
|
165
|
-
// KJC-TSK-1092: the host sandbox,
|
|
165
|
+
// KJC-TSK-1092/1241: the host sandbox, only when asked (kj harden --sandbox):
|
|
166
|
+
// inside it kj run cannot push, open the PR or reach Sonar on localhost.
|
|
166
167
|
if (sandbox) result.sandbox = installSandbox({ projectDir, logger }).state;
|
|
167
168
|
}
|
|
168
169
|
} catch (err) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0414 (#2606, from #2564): Solomon told the coder to drop a file from the commit as a
|
|
3
|
+
* mirror of another, but the other was a symlink to it: the link would be committed dangling.
|
|
4
|
+
* kj names the in-repo targets of the symlinks in a change, and a condition that takes one of
|
|
5
|
+
* them out of the commit is dropped before it reaches the coder.
|
|
6
|
+
*/
|
|
7
|
+
import { lstatSync, readlinkSync } from "node:fs";
|
|
8
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** The in-repo target of each symlink among `paths` (relative to projectDir). */
|
|
11
|
+
export function symlinkTargets(projectDir, paths) {
|
|
12
|
+
const root = resolve(projectDir);
|
|
13
|
+
return paths.flatMap((link) => {
|
|
14
|
+
try {
|
|
15
|
+
const abs = join(root, link);
|
|
16
|
+
if (!lstatSync(abs).isSymbolicLink()) return [];
|
|
17
|
+
const target = relative(root, resolve(dirname(abs), readlinkSync(abs)));
|
|
18
|
+
const outside = target === ".." || target.startsWith(`..${sep}`) || isAbsolute(target);
|
|
19
|
+
return target && !outside ? [{ link, target: target.split(sep).join("/") }] : [];
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A `*` matches within one path segment, as in the shell.
|
|
27
|
+
const segmentMatches = (pattern, name) => {
|
|
28
|
+
const parts = pattern.split("*");
|
|
29
|
+
if (parts.length === 1) return pattern === name;
|
|
30
|
+
if (!name.startsWith(parts[0]) || !name.endsWith(parts.at(-1))) return false;
|
|
31
|
+
let at = parts[0].length;
|
|
32
|
+
for (const part of parts.slice(1, -1)) {
|
|
33
|
+
const found = name.indexOf(part, at);
|
|
34
|
+
if (found < 0) return false;
|
|
35
|
+
at = found + part.length;
|
|
36
|
+
}
|
|
37
|
+
return at <= name.length - parts.at(-1).length;
|
|
38
|
+
};
|
|
39
|
+
const globMatches = (pattern, path) => {
|
|
40
|
+
const p = pattern.split("/");
|
|
41
|
+
const t = path.split("/");
|
|
42
|
+
return p.length === t.length && p.every((seg, i) => segmentMatches(seg, t[i]));
|
|
43
|
+
};
|
|
44
|
+
const REMOVES = /\b(unstage|remove|drop|exclude|delete|restore|reset|revert|rm|untrack|quitar|sacar|eliminar|excluir|borrar)\b/i;
|
|
45
|
+
// Quoted strings are one token (a path may hold spaces); bare tokens lose surrounding punctuation.
|
|
46
|
+
const TOKENS = /"([^"]+)"|'([^']+)'|`([^`]+)`|(\S+)/g;
|
|
47
|
+
const tokensOf = (text) => [...String(text).matchAll(TOKENS)]
|
|
48
|
+
.map((m) => (m[1] ?? m[2] ?? m[3] ?? m[4]).replace(/^[([{]+/, "").replace(/[)\]}:;,]+$/, ""));
|
|
49
|
+
// Named literally anywhere in the text (any character git allows), or by a glob token.
|
|
50
|
+
const names = (condition, target) => String(condition).includes(target) || tokensOf(condition).some((t) => globMatches(t, target));
|
|
51
|
+
|
|
52
|
+
/** Splits Solomon's conditions: the ones that would take a symlink's target out of the commit go to `dropped`. */
|
|
53
|
+
export function keepSymlinkTargets(conditions, targets) {
|
|
54
|
+
const takesTargetOut = (condition) => REMOVES.test(condition) && targets.some(({ target }) => names(condition, target));
|
|
55
|
+
const dropped = targets.length ? conditions.filter(takesTargetOut) : [];
|
|
56
|
+
return { kept: conditions.filter((c) => !dropped.includes(c)), dropped };
|
|
57
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* commands depend on what the project defines). Unknown language ⇒ no commands,
|
|
9
9
|
* leaving only the universal POSIX-shell checks.
|
|
10
10
|
*/
|
|
11
|
-
import { existsSync } from "node:fs";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
|
|
14
14
|
// KJC-BUG-0411 (#2563): uv and poetry keep pytest and ruff in the project's
|
|
@@ -32,6 +32,23 @@ const FORMAT_WRITE_BY_LANGUAGE = {
|
|
|
32
32
|
php: { command: "vendor/bin/php-cs-fixer fix", extensions: [".php"] },
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
// KJC-TSK-1239: JS/TS takes the formatter the project declares, from its node_modules.
|
|
36
|
+
// Declared both? The one its scripts call wins; else prettier, the more common.
|
|
37
|
+
const JS_TS = [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"];
|
|
38
|
+
const JS_FORMATTERS = [
|
|
39
|
+
{ pkg: "prettier", bin: "prettier", inScripts: /\bprettier\b/, command: "node_modules/.bin/prettier --write" },
|
|
40
|
+
{ pkg: "@biomejs/biome", bin: "biome", inScripts: /\bbiome\b/, command: "node_modules/.bin/biome format --write" },
|
|
41
|
+
];
|
|
42
|
+
function jsFormatWrite(projectDir) {
|
|
43
|
+
let pkg;
|
|
44
|
+
try { pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")); } catch { return null; }
|
|
45
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
46
|
+
const scripts = Object.values(pkg.scripts ?? {}).join("\n");
|
|
47
|
+
const usable = JS_FORMATTERS.filter((f) => deps[f.pkg] && existsSync(join(projectDir, "node_modules", ".bin", f.bin)));
|
|
48
|
+
const chosen = usable.find((f) => f.inScripts.test(scripts)) ?? usable[0];
|
|
49
|
+
return chosen ? { command: chosen.command, extensions: JS_TS } : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
const runnerFor = (language, projectDir) => (language === "python" && projectDir
|
|
36
53
|
? PYTHON_RUNNERS.find(([lock]) => existsSync(join(projectDir, lock)))?.[1] ?? null
|
|
37
54
|
: null);
|
|
@@ -46,6 +63,7 @@ export function commandsForLanguage(language, projectDir = null) {
|
|
|
46
63
|
|
|
47
64
|
/** The write-mode formatter of a language and the extensions it takes, or null. */
|
|
48
65
|
export function formatWriteFor(language, projectDir = null) {
|
|
66
|
+
if (language === "javascript" || language === "typescript") return projectDir ? jsFormatWrite(projectDir) : null;
|
|
49
67
|
const entry = FORMAT_WRITE_BY_LANGUAGE[language];
|
|
50
68
|
if (!entry) return null;
|
|
51
69
|
return { ...entry, command: [runnerFor(language, projectDir), entry.command].filter(Boolean).join(" ") };
|
package/src/mcp/server.js
CHANGED
|
@@ -7,6 +7,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
8
8
|
import { tools } from "./tools.js";
|
|
9
9
|
import { handleToolCall, responseText, enrichedFailPayload } from "./server-handlers.js";
|
|
10
|
+
import { pinGitDiffFormat } from "../utils/git-diff-format.js";
|
|
11
|
+
|
|
12
|
+
// KJC-BUG-0408: every git the MCP server runs prints a/ and b/, whatever the user's gitconfig says.
|
|
13
|
+
pinGitDiffFormat();
|
|
10
14
|
|
|
11
15
|
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
12
16
|
const PKG_PATH = path.resolve(MODULE_DIR, "../../package.json");
|
|
@@ -3,12 +3,35 @@ import { addCheckpoint, pauseSession, saveSession } from "../session/store.js";
|
|
|
3
3
|
import { setSuggestions } from "../session/mutators.js";
|
|
4
4
|
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
5
5
|
import { msg, getLang } from "../utils/messages.js";
|
|
6
|
+
import { listPendingPaths } from "../utils/git.js";
|
|
7
|
+
import { keepSymlinkTargets, symlinkTargets } from "../git/symlink-targets.js";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* @typedef {import("../types/solomon.js").Conflict} Conflict
|
|
9
11
|
* @typedef {import("../types/solomon.js").SolomonResult} SolomonResult
|
|
10
12
|
*/
|
|
11
13
|
|
|
14
|
+
// KJC-BUG-0414 (#2606): the symlinks among the pending paths, so Solomon never advises taking a
|
|
15
|
+
// link's target out of the commit. Unlistable is said, and Solomon goes on without the fact.
|
|
16
|
+
async function changeSymlinks(config, logger) {
|
|
17
|
+
const projectDir = config.projectDir || process.cwd();
|
|
18
|
+
try {
|
|
19
|
+
return symlinkTargets(projectDir, await listPendingPaths(projectDir));
|
|
20
|
+
} catch (err) {
|
|
21
|
+
logger.warn(`Solomon: the change's symlinks could not be listed (${err.message})`);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function dropSymlinkTargetConditions(ruling, symlinks, logger) {
|
|
27
|
+
const conditions = ruling?.result?.conditions;
|
|
28
|
+
if (!Array.isArray(conditions) || symlinks.length === 0) return;
|
|
29
|
+
const { kept, dropped } = keepSymlinkTargets(conditions, symlinks);
|
|
30
|
+
if (dropped.length === 0) return;
|
|
31
|
+
ruling.result.conditions = kept;
|
|
32
|
+
logger.warn(`Solomon: dropped ${dropped.length} condition(s) that took a symlink's target out of the commit: ${dropped.join(" | ")}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
/**
|
|
13
36
|
* Architectural invariant (DO NOT BREAK):
|
|
14
37
|
* `invokeSolomon` MUST NOT be called directly from stages, rules or agent
|
|
@@ -147,12 +170,15 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
|
|
|
147
170
|
try {
|
|
148
171
|
// Inject Solomon's own history so it doesn't repeat failed strategies
|
|
149
172
|
const solomonHistory = extractSolomonHistory(session);
|
|
173
|
+
const symlinks = await changeSymlinks(config, logger);
|
|
150
174
|
const enrichedConflict = {
|
|
151
175
|
...conflict,
|
|
152
176
|
...(pendingSuggestions && { hostSuggestions: pendingSuggestions }),
|
|
153
|
-
...(solomonHistory.length > 0 && { previousSolomonRulings: solomonHistory })
|
|
177
|
+
...(solomonHistory.length > 0 && { previousSolomonRulings: solomonHistory }),
|
|
178
|
+
...(symlinks.length > 0 && { symlinkTargets: symlinks })
|
|
154
179
|
};
|
|
155
180
|
ruling = await solomon.run({ conflict: enrichedConflict });
|
|
181
|
+
dropSymlinkTargetConditions(ruling, symlinks, logger);
|
|
156
182
|
} catch (err) {
|
|
157
183
|
logger.warn(`Solomon threw: ${err.message}`);
|
|
158
184
|
return escalateToHuman({
|
|
@@ -50,6 +50,9 @@ export function splitAddedByFile(diff) {
|
|
|
50
50
|
out.push(current);
|
|
51
51
|
continue;
|
|
52
52
|
}
|
|
53
|
+
// KJC-BUG-0408: a file header in another shape (mnemonic or no prefix) would leave its lines
|
|
54
|
+
// unscanned and the gate passing. A diff that cannot be read fails closed.
|
|
55
|
+
if (line.startsWith("diff --git ")) throw new Error(`cannot read the file headers of this diff (${line}): refusing to scan it as empty`);
|
|
53
56
|
if (!current) continue;
|
|
54
57
|
// `+++ b/x` is the header, not content; `+` alone is an added blank line.
|
|
55
58
|
if (line.startsWith("+") && !line.startsWith("+++")) current.lines.push(line.slice(1));
|
|
@@ -144,6 +144,12 @@ function buildPrompt({ conflict, task, instructions }) {
|
|
|
144
144
|
sections.push(`## Blocking issues\n${issueList}`);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// KJC-BUG-0414 (#2606): taking a symlink's target out of the commit leaves the link dangling.
|
|
148
|
+
if (conflict?.symlinkTargets?.length) {
|
|
149
|
+
const links = conflict.symlinkTargets.map(({ link, target }) => `- ${link} -> ${target}`).join("\n");
|
|
150
|
+
sections.push(`## Symlinks in this change\n${links}\nEach target stays in the commit: never propose unstaging, removing or restoring it, or the link is committed dangling.`);
|
|
151
|
+
}
|
|
152
|
+
|
|
147
153
|
if (task) {
|
|
148
154
|
sections.push(`## Original task\n${task}`);
|
|
149
155
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0408 (#2560): kj reads the diffs git prints for it (privacy, sonar, review, guards),
|
|
3
|
+
* and every parser expects `a/` and `b/`. A user's diff.mnemonicPrefix (c/, i/, w/), noprefix or
|
|
4
|
+
* srcPrefix changed those headers: the suite went red on that machine and the privacy gate saw
|
|
5
|
+
* no file and passed. kj pins the format of the git it runs through the environment (the
|
|
6
|
+
* GIT_CONFIG_COUNT protocol, git >= 2.31), so every child inherits it and no parser depends on
|
|
7
|
+
* the user's gitconfig. The pins are appended after whatever config the environment carries.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// KJC-BUG-0415 (#2622): a verdict binds to the hash of the diff text, so every option that
|
|
11
|
+
// changes that text is pinned to git's default too, or the hash kj review --staged records on a
|
|
12
|
+
// machine with diff.algorithm=histogram is not the one CI recomputes.
|
|
13
|
+
const PINS = [
|
|
14
|
+
["diff.mnemonicPrefix", "false"],
|
|
15
|
+
["diff.noprefix", "false"],
|
|
16
|
+
["diff.srcPrefix", "a/"],
|
|
17
|
+
["diff.dstPrefix", "b/"],
|
|
18
|
+
["diff.algorithm", "myers"],
|
|
19
|
+
["diff.context", "3"],
|
|
20
|
+
["diff.interHunkContext", "0"],
|
|
21
|
+
["diff.indentHeuristic", "true"],
|
|
22
|
+
["diff.suppressBlankEmpty", "false"],
|
|
23
|
+
["diff.renames", "true"],
|
|
24
|
+
["diff.relative", "false"],
|
|
25
|
+
["color.diff", "false"],
|
|
26
|
+
];
|
|
27
|
+
const PINNED = "KJ_GIT_DIFF_PINNED";
|
|
28
|
+
|
|
29
|
+
/** Pin git's diff headers to a/ and b/ for every git that inherits `env`; idempotent. */
|
|
30
|
+
export function pinGitDiffFormat(env = process.env) {
|
|
31
|
+
if (env[PINNED]) return env;
|
|
32
|
+
const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? "0", 10) || 0;
|
|
33
|
+
PINS.forEach(([key, value], i) => {
|
|
34
|
+
env[`GIT_CONFIG_KEY_${count + i}`] = key;
|
|
35
|
+
env[`GIT_CONFIG_VALUE_${count + i}`] = value;
|
|
36
|
+
});
|
|
37
|
+
env.GIT_CONFIG_COUNT = String(count + PINS.length);
|
|
38
|
+
env[PINNED] = "1";
|
|
39
|
+
return env;
|
|
40
|
+
}
|