@karajan-family/code 4.66.0 → 4.67.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-family/code",
3
- "version": "4.66.0",
3
+ "version": "4.67.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",
@@ -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");
@@ -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)
@@ -50,15 +50,16 @@ export function guardCommand(guard, { blocking = false, viaKj = isSea() || !kjHo
50
50
  return `if command -v ${bin} >/dev/null 2>&1; then ${run}; else ${missing}${blocking ? "; exit 2" : ""}; fi`;
51
51
  }
52
52
 
53
- /** The wiring kj harden wrote before ADR 0030: node running the project's copy. */
54
- const withoutLegacy = (list, script) => {
55
- const legacy = `node ${join(".karajan", "harness", script)}`;
56
- return list.flatMap((e) => {
57
- if (!Array.isArray(e?.hooks) || !e.hooks.some((h) => h?.command === legacy)) return [e];
58
- const hooks = e.hooks.filter((h) => h?.command !== legacy);
59
- return hooks.length ? [{ ...e, hooks }] : [];
60
- });
61
- };
53
+ /**
54
+ * Drop the commands that ran this guard some other way: node on the project's copy
55
+ * (before ADR 0030) or the other runner (KJC-BUG-0403: switching runner must not
56
+ * leave the guard wired twice).
57
+ */
58
+ const withoutStale = (list, stale) => list.flatMap((e) => {
59
+ if (!Array.isArray(e?.hooks) || !e.hooks.some((h) => stale.includes(h?.command))) return [e];
60
+ const hooks = e.hooks.filter((h) => !stale.includes(h?.command));
61
+ return hooks.length ? [{ ...e, hooks }] : [];
62
+ });
62
63
 
63
64
  /**
64
65
  * KJC-TSK-1206 (ADR 0030): the wiring is what decides which guard runs, so it is
@@ -98,7 +99,7 @@ export function wiringState({ projectDir, entries }) {
98
99
  * basename; an entry is present when that script already appears under the
99
100
  * same event (and matcher, when given).
100
101
  */
101
- export function mergeClaudeHooks({ projectDir, logger = console, entries }) {
102
+ export function mergeClaudeHooks({ projectDir, logger = console, entries, viaKj = isSea() || !kjHookOnPath() }) {
102
103
  const settingsPath = join(projectDir, ".claude", "settings.json");
103
104
  let settings = {};
104
105
  if (existsSync(settingsPath)) {
@@ -116,10 +117,12 @@ export function mergeClaudeHooks({ projectDir, logger = console, entries }) {
116
117
  return { wired: false };
117
118
  }
118
119
  }
119
- const viaKj = isSea() || !kjHookOnPath();
120
120
  for (const { event, matcher, script } of entries) {
121
- const list = withoutLegacy(Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [], script);
122
- const command = guardCommand(script.replace(/\.mjs$/, ""), { blocking: event === "PreToolUse", viaKj });
121
+ const guard = script.replace(/\.mjs$/, "");
122
+ const blocking = event === "PreToolUse";
123
+ const stale = [`node ${join(".karajan", "harness", script)}`, guardCommand(guard, { blocking, viaKj: !viaKj })];
124
+ const list = withoutStale(Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [], stale);
125
+ const command = guardCommand(guard, { blocking, viaKj });
123
126
  const present = list.some(
124
127
  (e) => (e?.hooks || []).some((h) => h?.command === command) && (matcher === undefined || e?.matcher === matcher),
125
128
  );
@@ -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 } 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,28 @@ 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
+ const runnerFor = (language, projectDir) => (language === "python" && projectDir
36
+ ? PYTHON_RUNNERS.find(([lock]) => existsSync(join(projectDir, lock)))?.[1] ?? null
37
+ : null);
38
+
39
+ /** Native commands for a language, or {} when JS/TS or unknown; python ones through uv/poetry when the project uses them. */
40
+ export function commandsForLanguage(language, projectDir = null) {
41
+ const cmds = { ...COMMANDS_BY_LANGUAGE[language] };
42
+ const runner = runnerFor(language, projectDir);
43
+ if (runner) for (const [step, command] of Object.entries(cmds)) cmds[step] = `${runner} ${command}`;
44
+ return cmds;
45
+ }
46
+
47
+ /** The write-mode formatter of a language and the extensions it takes, or null. */
48
+ export function formatWriteFor(language, projectDir = null) {
49
+ const entry = FORMAT_WRITE_BY_LANGUAGE[language];
50
+ if (!entry) return null;
51
+ return { ...entry, command: [runnerFor(language, projectDir), entry.command].filter(Boolean).join(" ") };
25
52
  }
@@ -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
  }