karajan-code 4.66.1 → 4.68.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.66.1",
3
+ "version": "4.68.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",
@@ -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("--no-sandbox", "Leave the host Bash sandbox alone (by default it is turned on where it can start)")
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 !== false,
1141
+ sandbox: flags.sandbox === true,
1141
1142
  mutation: Boolean(flags.mutation),
1142
1143
  only: flags.only ?? [],
1143
1144
  exclude: flags.exclude ?? [],
@@ -44,7 +44,7 @@ const JS_TEST_CMD = {
44
44
  */
45
45
  export async function resolveCmds(projectDir, language) {
46
46
  const lang = language ?? detectStackRoots(projectDir)[0]?.language ?? null;
47
- const cmds = { ...commandsForLanguage(lang) };
47
+ const cmds = commandsForLanguage(lang, projectDir);
48
48
  if (lang !== "javascript" && lang !== "typescript") return cmds;
49
49
 
50
50
  const pkgPath = join(projectDir, "package.json");
@@ -92,7 +92,7 @@ export async function hardenCommand({
92
92
  only = [],
93
93
  exclude = [],
94
94
  commitSupervisor = false,
95
- sandbox = true,
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, on by default wherever it can start here.
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) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { addCheckpoint } from "../session/store.js";
7
+ import { setGitPreexisting } from "../session/mutators.js";
7
8
  import { pipelineSonarBlock, stampStagedVerdict } from "../review/verdict-store.js";
8
9
  import { branchSize } from "../commands/pr-size.js";
9
10
  import { pushRemedy } from "./hu-automation.js";
@@ -80,7 +81,11 @@ function prefixForTaskType(taskType) {
80
81
  }
81
82
 
82
83
  export function commitMessageFromTask(task, taskType = null) {
83
- const clean = String(task || "")
84
+ // KJC-BUG-0413 (#2565): the header comes from the first paragraph, without
85
+ // its leading Markdown marks; the whole task made "feat: # Roadmap ... Context: ...".
86
+ const firstParagraph = String(task || "").trim().split(/\n[ \t]*\r?\n/)[0];
87
+ const clean = firstParagraph
88
+ .replace(/^[#>*\-\s]+/, "")
84
89
  .replaceAll(/\s+/g, " ")
85
90
  .trim();
86
91
  const prefix = prefixForTaskType(taskType);
@@ -119,9 +124,22 @@ async function checkoutRunBranch(created) {
119
124
  }
120
125
  }
121
126
 
122
- export async function prepareGitAutomation({ config, task, logger, session }) {
127
+ export async function prepareGitAutomation({ config, task, logger, session, preexisting: resumedBaseline = null }) {
123
128
  const enabled = config.git.auto_commit || config.git.auto_push || config.git.auto_pr;
124
- if (!enabled) return { enabled: false };
129
+ if (!enabled) {
130
+ // KJC-TSK-1237: the format stage needs the baseline also when kj does not
131
+ // commit; outside git there is none, and that stage says so.
132
+ let baseline = resumedBaseline;
133
+ if (!baseline) {
134
+ try {
135
+ baseline = await listPendingPaths();
136
+ } catch {
137
+ baseline = null;
138
+ }
139
+ }
140
+ if (session && Array.isArray(baseline)) setGitPreexisting(session, baseline);
141
+ return { enabled: false };
142
+ }
125
143
 
126
144
  if (!(await ensureGitRepo())) {
127
145
  throw new Error("Git automation requested but current directory is not a git repository");
@@ -147,7 +165,10 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
147
165
  // pipeline commits only what it changes. Both names of a pending rename are
148
166
  // kept. Fails closed: with no answer from git the run stops here rather than
149
167
  // commit the person's pending changes later.
150
- const preexisting = await listPendingPaths();
168
+ // KJC-BUG-0412 (#2564): a resumed run keeps the baseline of the session it
169
+ // resumes; the tree now also holds that session's own work.
170
+ const preexisting = resumedBaseline ?? await listPendingPaths();
171
+ if (session) setGitPreexisting(session, preexisting);
151
172
  const repoHasCommits = await hasCommits();
152
173
 
153
174
  // New repo without commits: create branch directly (no fetch/sync possible)
@@ -8,6 +8,12 @@
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, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ // KJC-BUG-0411 (#2563): uv and poetry keep pytest and ruff in the project's
15
+ // venv, where a bare command exits 127; their manager runs them.
16
+ const PYTHON_RUNNERS = [["uv.lock", "uv run"], ["poetry.lock", "poetry run"]];
11
17
 
12
18
  export const COMMANDS_BY_LANGUAGE = {
13
19
  python: { lint: "ruff check .", format: "ruff format --check .", test: "pytest" },
@@ -19,7 +25,46 @@ export const COMMANDS_BY_LANGUAGE = {
19
25
  },
20
26
  };
21
27
 
22
- /** Native commands for a language, or {} when JS/TS or unknown. */
23
- export function commandsForLanguage(language) {
24
- return COMMANDS_BY_LANGUAGE[language] ?? {};
28
+ // KJC-TSK-1237 (#2565): the formatter in write mode, run on the files of a session.
29
+ const FORMAT_WRITE_BY_LANGUAGE = {
30
+ python: { command: "ruff format", extensions: [".py", ".pyi"] },
31
+ go: { command: "gofmt -w", extensions: [".go"] },
32
+ php: { command: "vendor/bin/php-cs-fixer fix", extensions: [".php"] },
33
+ };
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
+
52
+ const runnerFor = (language, projectDir) => (language === "python" && projectDir
53
+ ? PYTHON_RUNNERS.find(([lock]) => existsSync(join(projectDir, lock)))?.[1] ?? null
54
+ : null);
55
+
56
+ /** Native commands for a language, or {} when JS/TS or unknown; python ones through uv/poetry when the project uses them. */
57
+ export function commandsForLanguage(language, projectDir = null) {
58
+ const cmds = { ...COMMANDS_BY_LANGUAGE[language] };
59
+ const runner = runnerFor(language, projectDir);
60
+ if (runner) for (const [step, command] of Object.entries(cmds)) cmds[step] = `${runner} ${command}`;
61
+ return cmds;
62
+ }
63
+
64
+ /** The write-mode formatter of a language and the extensions it takes, or null. */
65
+ export function formatWriteFor(language, projectDir = null) {
66
+ if (language === "javascript" || language === "typescript") return projectDir ? jsFormatWrite(projectDir) : null;
67
+ const entry = FORMAT_WRITE_BY_LANGUAGE[language];
68
+ if (!entry) return null;
69
+ return { ...entry, command: [runnerFor(language, projectDir), entry.command].filter(Boolean).join(" ") };
25
70
  }
@@ -47,7 +47,7 @@ export function adoptPreLoopConfig(ctx, updatedConfig) {
47
47
  ctx.coderRoleInstance.config = updatedConfig;
48
48
  }
49
49
 
50
- export async function initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags }) {
50
+ export async function initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags, preexisting = null }) {
51
51
  // Auto-init .karajan/ if missing (copies coder-rules, review-rules, role templates)
52
52
  const initProjectDir = config.projectDir || process.cwd();
53
53
  await autoInit(initProjectDir, logger);
@@ -229,7 +229,7 @@ export async function initFlowContext({ task, config, logger, emitter, askQuesti
229
229
  ctx.journalDecisions = [];
230
230
  }
231
231
 
232
- ctx.gitCtx = await prepareGitAutomation({ config: ctx.config, task, logger, session: ctx.session });
232
+ ctx.gitCtx = await prepareGitAutomation({ config: ctx.config, task, logger, session: ctx.session, preexisting });
233
233
  const projectDir = ctx.config.projectDir || process.cwd();
234
234
  ctx.reviewRules = (await resolveReviewProfile({ mode: ctx.config.review_mode, projectDir })).rules;
235
235
  await ctx.coderRoleInstance.init();
@@ -23,9 +23,13 @@ import { runPerfStage } from "../../stages/perf-stage.js";
23
23
  import { runTddDisciplineStage } from "../../stages/tdd-discipline-stage.js";
24
24
  import { runSuiteGreenStage, suiteGreenRequired } from "../../stages/suite-green-stage.js";
25
25
  import { runToolJudgeStage } from "../../stages/tool-judge-stage.js";
26
+ import { runFormatStage } from "../../stages/format-stage.js";
26
27
  import { tryCiComment } from "../../ci-integration.js";
27
28
 
28
29
  export async function runQualityGateStages({ config, logger, emitter, eventBase, session, trackBudget, i, askQuestion, repeatDetector, budgetSummary, sonarState, task, stageResults, coderRole, pipelineFlags, brainCtx }) {
30
+ // KJC-TSK-1237 (#2565): the coder's files reach every gate formatted.
31
+ await runFormatStage({ config, logger, emitter, eventBase, session });
32
+
29
33
  const tddResult = await runTddCheckStage({ config, logger, emitter, eventBase, session, trackBudget, iteration: i, askQuestion, task, brainCtx });
30
34
  if (tddResult.action === "pause") return { action: "return", result: tddResult.result };
31
35
  if (tddResult.action === "continue") return { action: "continue" };
@@ -112,7 +112,7 @@ export async function runFlow(opts) {
112
112
  return withRunContext({}, () => _runFlowInner(opts));
113
113
  }
114
114
 
115
- async function _runFlowInner({ task, config, logger, flags = {}, emitter = null, askQuestion = null, pgTaskId = null, pgProject = null }) {
115
+ async function _runFlowInner({ task, config, logger, flags = {}, emitter = null, askQuestion = null, pgTaskId = null, pgProject = null, preexisting = null }) {
116
116
  // Defensive test-harness resolution for callers that bypass loadConfig()
117
117
  // (orchestrator unit tests that build raw config objects). Production
118
118
  // callers go through src/config/loader.js → loadConfig which already
@@ -153,7 +153,7 @@ async function _runFlowInner({ task, config, logger, flags = {}, emitter = null,
153
153
 
154
154
  let ctx;
155
155
  try {
156
- ctx = await initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags });
156
+ ctx = await initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags, preexisting });
157
157
  } catch (initError) {
158
158
  // Pre-loop / preflight failures are CONFIG problems, not pipeline
159
159
  // disputes. Pre-v2.7.5 we escalated them to Solomon, which:
@@ -356,7 +356,8 @@ export async function resumeFlow({ sessionId, answer, config, logger, flags = {}
356
356
 
357
357
  // Re-run the flow with the existing session context
358
358
  try {
359
- return await runFlow({ task, config: sessionConfig, logger, flags, emitter, askQuestion });
359
+ // KJC-BUG-0412: what was pending is what was pending when THIS session started.
360
+ return await runFlow({ task, config: sessionConfig, logger, flags, emitter, askQuestion, preexisting: session.git_preexisting ?? null });
360
361
  } catch (err) {
361
362
  await markSessionStatus(session, "failed");
362
363
  throw err;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * KJC-TSK-1237 (#2565): no stage formatted the coder's files, so a pre-commit
3
+ * format check aborted the final commit after every gate was green, and a
4
+ * one-command fix reached Solomon. This stage runs the project's formatter in
5
+ * write mode (the one kj harden knows) on the files of this session only:
6
+ * pending now and not pending when the session started. It is not a gate: a
7
+ * failing or missing formatter is said and the next stages judge the files.
8
+ * Opt-out: `development.format_after_coder: false`.
9
+ */
10
+ import { existsSync, readdirSync } from "node:fs";
11
+ import { extname, join } from "node:path";
12
+ import { emitProgress, makeEvent } from "../../utils/events.js";
13
+ import { runCommand } from "../../utils/process.js";
14
+ import { listPendingPaths } from "../../utils/git.js";
15
+ import { detectStackRoots } from "../../harden/stack-roots.js";
16
+ import { formatWriteFor } from "../../harden/hook-commands.js";
17
+
18
+ const TIMEOUT_MS = 2 * 60 * 1000;
19
+ const primaryLanguage = (projectDir) => detectStackRoots(projectDir)[0]?.language ?? null;
20
+
21
+ const expand = (projectDir, p) => (p.endsWith("/")
22
+ ? readdirSync(join(projectDir, p), { recursive: true }).map((f) => join(p, f))
23
+ : [p]);
24
+
25
+ /** Files of the session the formatter takes; git names a new directory, so its files are listed. */
26
+ function filesToFormat({ pending, preexisting, extensions, projectDir }) {
27
+ const before = new Set(preexisting);
28
+ return pending.filter((p) => !before.has(p) && existsSync(join(projectDir, p)))
29
+ .flatMap((p) => expand(projectDir, p))
30
+ .filter((p) => extensions.includes(extname(p)));
31
+ }
32
+
33
+ /** @returns {Promise<{ran: boolean, ok?: boolean, files?: string[]}>} */
34
+ export async function runFormatStage({ config, logger, emitter, eventBase, session, detectLanguageImpl = primaryLanguage, listPendingImpl = (dir) => listPendingPaths(dir), runImpl = async (...args) => runCommand(...args) }) {
35
+ if (config?.development?.format_after_coder === false) return { ran: false };
36
+ const projectDir = config?.projectDir || process.cwd();
37
+ const formatter = formatWriteFor(detectLanguageImpl(projectDir), projectDir);
38
+ if (!formatter) return { ran: false };
39
+ const preexisting = session?.git_preexisting;
40
+ if (!Array.isArray(preexisting)) {
41
+ logger?.warn?.("format: no baseline of what was pending when the session started: nothing formatted");
42
+ return { ran: false };
43
+ }
44
+ let files;
45
+ try {
46
+ files = filesToFormat({ pending: await listPendingImpl(projectDir), preexisting, extensions: formatter.extensions, projectDir });
47
+ } catch (err) {
48
+ logger?.warn?.(`format: the session's files could not be listed (${err.message}): nothing formatted`);
49
+ return { ran: false };
50
+ }
51
+ if (files.length === 0) return { ran: false };
52
+ const [bin, ...args] = formatter.command.split(" ");
53
+ const res = await runImpl(bin, [...args, "--", ...files], { cwd: projectDir, timeout: TIMEOUT_MS }).catch(() => ({ exitCode: null }));
54
+ const ok = res.exitCode === 0;
55
+ const message = ok
56
+ ? `format: ${formatter.command} on ${files.length} file(s)`
57
+ : `format: ${formatter.command} failed (exit ${res.exitCode ?? "none"}); the next stages judge the files as they are`;
58
+ logger?.[ok ? "info" : "warn"]?.(message);
59
+ emitProgress(emitter, makeEvent("format:result", { ...eventBase, stage: "format" }, { status: ok ? "ok" : "warn", message, detail: { command: formatter.command, files } }));
60
+ return { ran: true, ok, files };
61
+ }
@@ -4,6 +4,8 @@
4
4
  * The rules are the own-work ones: the same budget, Conventional Commits and
5
5
  * attribution patterns, and tests-with-code.
6
6
  */
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
7
9
  import { AI_ATTRIBUTION, CONVENTIONAL_TYPES } from "../harden/hook-templates.js";
8
10
  import { loadPolicy } from "../policy/engine.js";
9
11
  import { runCommand } from "../utils/process.js";
@@ -59,12 +61,35 @@ export function commitChecks(commits) {
59
61
  ];
60
62
  }
61
63
 
62
- /** Source changed with no test changed (a delete-only change needs none). */
64
+ // KJC-TSK-1238 (#2263): a convention the reviewed repo declares (commitlint, kj's commit-msg hook).
65
+ const CONVENTION_FILES = [
66
+ "commitlint.config.js", "commitlint.config.cjs", "commitlint.config.mjs", "commitlint.config.ts",
67
+ ".commitlintrc", ".commitlintrc.json", ".commitlintrc.yml", ".commitlintrc.yaml", ".commitlintrc.js", ".commitlintrc.cjs",
68
+ ".karajan/hooks/commit-msg",
69
+ ];
70
+
71
+ export function declaresCommitConvention(projectDir) {
72
+ if (CONVENTION_FILES.some((f) => existsSync(join(projectDir, f)))) return true;
73
+ try {
74
+ return Boolean(JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")).commitlint);
75
+ } catch {
76
+ return false; // no package.json, or not JSON: it declares nothing
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Source changed with no test changed (a delete-only change needs none). On
82
+ * someone else's PR test-first is kj's rule, not theirs (KJC-TSK-1238): a
83
+ * change with no business logic needs no test, so it is a question, not a failure.
84
+ */
63
85
  export function testsCheck({ config, numstat }) {
64
86
  const rows = rowsOf(numstat);
65
87
  const res = checkTestsWithCode({ config, stagedFiles: rows.map((r) => r.file), numstat: rows });
66
- const ok = res.ok && res.mode !== "warn";
67
- return { id: "tests", ok, detail: ok ? `tests: ${res.mode}` : res.reason || "source changed with no test changed" };
88
+ if (res.ok && res.mode !== "warn") return { id: "tests", ok: true, detail: `tests: ${res.mode}` };
89
+ return {
90
+ id: "tests", ok: null, detail: res.reason || "source changed with no test changed",
91
+ question: "No test changed: does the change touch business logic, behaviour a unit, integration or E2E test could observe? A plain index, configuration or infrastructure needs none.",
92
+ };
68
93
  }
69
94
 
70
95
  const git = async (projectDir, args) => {
@@ -83,5 +108,9 @@ export async function prRequirementChecks({ range, projectDir, config, run = git
83
108
  const [sha, subject = "", body = ""] = r.split("\x1f");
84
109
  return { sha, subject, body };
85
110
  });
86
- return [sizeCheck({ numstat, limit: prSizeLimit({ projectDir, config }).limit }), ...commitChecks(commits), testsCheck({ config, numstat })];
111
+ const [conventional, attribution] = commitChecks(commits);
112
+ const commitFormat = declaresCommitConvention(projectDir)
113
+ ? conventional
114
+ : { id: "conventional-commits", ok: null, skipped: true, detail: "the repo declares no commit convention (no commitlint config, no kj commit-msg hook): not checked" };
115
+ return [sizeCheck({ numstat, limit: prSizeLimit({ projectDir, config }).limit }), commitFormat, attribution, testsCheck({ config, numstat })];
87
116
  }
@@ -227,6 +227,11 @@ export function setPgTaskId(session, id) {
227
227
  session.pg_task_id = id;
228
228
  }
229
229
 
230
+ /** KJC-BUG-0412: what was pending in the tree when the session started. */
231
+ export function setGitPreexisting(session, paths) {
232
+ session.git_preexisting = paths;
233
+ }
234
+
230
235
  export function appendPgCommits(session, commits) {
231
236
  session.pg_commits = [...(session.pg_commits ?? []), ...commits];
232
237
  }