karajan-code 4.44.0 → 4.45.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.44.0",
3
+ "version": "4.45.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",
@@ -40,6 +40,8 @@ const runner = (projectDir, env) => (args) => execFileSync("git", ["-C", project
40
40
  // or under a stack root (a fullstack monorepo is hardened on every side).
41
41
  const isContract = (file) =>
42
42
  CONTRACT_PATHS.some((p) => (p.endsWith("/") || p.endsWith("-") ? file.startsWith(p) : file === p)) || GENERATED_CONFIG_FILES.has(basename(file));
43
+ /** The supervisor's files: the hooks and their provenance, sealed by the human (ADR 0009). */
44
+ const isSupervisor = (file) => file.startsWith(".karajan/hooks/") || file === ".karajan/supervisor-provenance.json";
43
45
 
44
46
  /** The contract files git sees as changed, with their porcelain code (`??` untracked). */
45
47
  function contractStatus(git) {
@@ -70,9 +72,14 @@ const branchOf = (git) => { try { return git(["symbolic-ref", "--short", "HEAD"]
70
72
  */
71
73
  export function commitContract({ projectDir, before = new Set(), baseBranch = "main", env = process.env }) {
72
74
  const git = runner(projectDir, env);
73
- const files = contractStatus(git).map(([file]) => file).filter((file) => !before.has(file)).sort();
74
- if (files.length === 0) return { committed: false, reason: "nothing of the contract to commit" };
75
75
  const history = hasCommits(git);
76
+ // KJC-BUG-0302: a sealed supervisor regenerated (tracked hooks or provenance
77
+ // that changed) is the seal's, a human act (ADR 0009), never the contract
78
+ // commit's: regenerated together after a version bump, the gate refused the one
79
+ // and the seal the other, and the person had to unstage the hook by hand. The
80
+ // first install (untracked hooks) still rides in the contract commit.
81
+ const files = contractStatus(git).filter(([file, code]) => !before.has(file) && !(history && code !== "??" && isSupervisor(file))).map(([file]) => file).sort();
82
+ if (files.length === 0) return { committed: false, reason: "nothing of the contract to commit" };
76
83
  if (history && branchOf(git) === baseBranch) {
77
84
  // kj never commits on the base branch, and will not switch the person's branch
78
85
  // for them: the files are named so the commit can be made where it belongs.
@@ -83,7 +90,10 @@ export function commitContract({ projectDir, before = new Set(), baseBranch = "m
83
90
  // --only: these paths and nothing else. What the person had staged stays staged.
84
91
  git(["commit", "--only", "-m", history ? REGEN_MESSAGE : FRESH_MESSAGE, "--", ...files]);
85
92
  } catch (err) {
86
- return { committed: false, files, reason: `git could not commit the contract: ${String(err.stderr || err.message).trim().split("\n")[0]}` };
93
+ // KJC-BUG-0302: the files stay staged, and the reason names the way to commit them.
94
+ const why = String(err.stderr || err.message).trim().split("\n")[0];
95
+ const quoted = files.map((f) => "'" + f.replaceAll("'", String.raw`'\''`) + "'").join(" ");
96
+ return { committed: false, files, reason: `git could not commit the contract: ${why}. Commit these generated files on a branch yourself: git add -- ${quoted} && kj review --staged && git commit -m "chore(kj): el contrato del método, generado por kj"` };
87
97
  }
88
98
  return { committed: true, files };
89
99
  }
@@ -32,6 +32,8 @@ import {
32
32
  } from "../../session/mutators.js";
33
33
  import { emitProgress, makeEvent } from "../../utils/events.js";
34
34
  import { runTesterStage, runSecurityStage, runFinalAuditStage } from "../post-loop-stages.js";
35
+ import { runAcceptanceCoverageStage } from "../stages/acceptance-coverage-stage.js";
36
+ import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
35
37
  import { invokeSolomon } from "../solomon-escalation.js";
36
38
  import { tryCiComment } from "../ci-integration.js";
37
39
  import { getIntegration } from "../integrations.js";
@@ -103,6 +105,23 @@ export async function handlePostLoopStages({ config, session, emitter, eventBase
103
105
  return { action: "continue" };
104
106
  }
105
107
 
108
+ // KJC-TSK-0987 (#1894): with a card, each acceptance criterion is read against
109
+ // the diff by a different AI before the run is approved. An uncovered one goes
110
+ // back to the coder; a judge with no verdict stops the approval, never grants it.
111
+ const coverage = await runAcceptanceCoverageStage({
112
+ config, logger, emitter, eventBase, trackBudget, iteration: i, task,
113
+ criteria: criteriaOfCard(session.pg_card), diff: postLoopDiff,
114
+ });
115
+ if (coverage.stageResult) stageResults.acceptanceCoverage = coverage.stageResult;
116
+ if (coverage.action === "retry") {
117
+ setReviewerFeedback(session, coverage.feedback);
118
+ await saveSession(session);
119
+ return { action: "continue" };
120
+ }
121
+ if (coverage.action === "fail") {
122
+ return { action: "return", result: { approved: false, sessionId: session.id, reason: "acceptance_coverage_unavailable", error: coverage.stageResult.summary } };
123
+ }
124
+
106
125
  return { action: "proceed" };
107
126
  }
108
127
 
@@ -32,6 +32,9 @@ import { runRepair } from "../repair/repair-runner.js";
32
32
  import { writeHistoryRecord } from "./post-loop.js";
33
33
  import { setReviewerFeedback } from "../../session/mutators.js";
34
34
  import { createHuSnapshot } from "../../git/hu-snapshot.js";
35
+ import { generateDiff } from "../../review/diff-generator.js";
36
+ import { runAcceptanceCoverageStage } from "../stages/acceptance-coverage-stage.js";
37
+ import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
35
38
 
36
39
  /**
37
40
  * @param {object} args
@@ -422,12 +425,28 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
422
425
  // the host reads, not only in a warn line of the log.
423
426
  const warnings = collectGitWarnings(subPipelineResult.results);
424
427
  for (const w of warnings) logger.warn(`git automation: ${w}`);
428
+ // KJC-TSK-0987 (#1894): the HUs approved themselves against their own stories;
429
+ // the card's criteria are read against the diff of the whole run before the
430
+ // run says approved. Uncovered: approved:false with the list, for the host.
431
+ let coverage = { action: "skip" };
432
+ if (subPipelineResult.approved) {
433
+ const diff = await generateDiff({ baseRef: ctx.session.head_at_start || ctx.session.session_start_sha });
434
+ coverage = await runAcceptanceCoverageStage({
435
+ config: ctx.config, logger, emitter, eventBase: ctx.eventBase, trackBudget: ctx.trackBudget,
436
+ iteration: 0, task, criteria: criteriaOfCard(ctx.session.pg_card), diff,
437
+ });
438
+ if (coverage.stageResult) ctx.stageResults.acceptanceCoverage = coverage.stageResult;
439
+ }
440
+ const uncovered = coverage.action === "retry" ? { reason: "acceptance_criteria_uncovered", uncovered: coverage.stageResult.uncovered } : {};
441
+ const unavailable = coverage.action === "fail" ? { reason: "acceptance_coverage_unavailable", error: coverage.stageResult.summary } : {};
425
442
  const finalResult = {
426
- approved: subPipelineResult.approved,
443
+ approved: subPipelineResult.approved && coverage.action !== "retry" && coverage.action !== "fail",
427
444
  sessionId: ctx.session.id,
428
445
  huResults: subPipelineResult.results,
429
446
  blockedIds: subPipelineResult.blockedIds,
430
447
  planId: ctx.session._planRef?.planId || null,
448
+ ...uncovered,
449
+ ...unavailable,
431
450
  ...(warnings.length > 0 ? { warnings } : {})
432
451
  };
433
452
  await writeHistoryRecord({ sessionId: ctx.session.id, task, result: finalResult, logger });
@@ -0,0 +1,47 @@
1
+ // KJC-TSK-0987 (issue #1894): the acceptance-coverage stage. With a card whose
2
+ // criteria the run was meant to implement, a different AI reads each criterion
3
+ // against the diff before the run is approved. No card or no criteria: the
4
+ // stage does not run and costs nothing. An unreadable verdict is the judge's
5
+ // failure, never an approval.
6
+ import { AcceptanceCoverageRole } from "../../roles/acceptance-coverage-role.js";
7
+ import { emitProgress, makeEvent } from "../../utils/events.js";
8
+
9
+ const STAGE = "acceptance-coverage";
10
+
11
+ /** The feedback the coder gets: the criteria with no evidence in the diff. */
12
+ export const uncoveredFeedback = (uncovered) => {
13
+ const lines = uncovered.map((c) => "- [" + c.index + "] " + c.text + (c.evidence ? " (judge: " + c.evidence + ")" : ""));
14
+ return "Acceptance criteria NOT covered by the diff (implement them, with tests that exercise them):\n" + lines.join("\n");
15
+ };
16
+
17
+ /**
18
+ * @returns {Promise<{action: "skip"|"ok"|"retry"|"fail", stageResult?: object, feedback?: string}>}
19
+ */
20
+ export async function runAcceptanceCoverageStage({ config, logger, emitter, eventBase, trackBudget, iteration, task, criteria, diff, RoleClass = AcceptanceCoverageRole }) {
21
+ if (!Array.isArray(criteria) || criteria.length === 0) return { action: "skip" };
22
+ logger.setContext?.({ iteration, stage: STAGE });
23
+ emitProgress(emitter, makeEvent(`${STAGE}:start`, { ...eventBase, stage: STAGE }, {
24
+ message: `Acceptance coverage: ${criteria.length} criteria against the diff`,
25
+ detail: { criteria: criteria.length, executorType: "agent" },
26
+ }));
27
+ const role = new RoleClass({ config, logger, emitter });
28
+ await role.init({ task, iteration });
29
+ const started = Date.now();
30
+ const output = await role.run({ task, criteria, diff });
31
+ const provider = output.result?.provider || role.resolveProvider();
32
+ trackBudget?.({ role: STAGE, provider, model: config?.roles?.[STAGE]?.model || null, result: output, duration_ms: Date.now() - started });
33
+
34
+ if (!output.ok) {
35
+ const stageResult = { ok: false, summary: output.summary || "acceptance-coverage: the judge gave no verdict", provider, error: output.result?.error || null };
36
+ emitProgress(emitter, makeEvent(`${STAGE}:end`, { ...eventBase, stage: STAGE }, { status: "fail", message: stageResult.summary, detail: stageResult }));
37
+ return { action: "fail", stageResult };
38
+ }
39
+ const { criteria: verdicts, uncovered } = output.result;
40
+ const stageResult = { ok: uncovered.length === 0, summary: output.summary, provider, criteria: verdicts, uncovered };
41
+ emitProgress(emitter, makeEvent(`${STAGE}:end`, { ...eventBase, stage: STAGE }, {
42
+ status: stageResult.ok ? "ok" : "fail", message: output.summary, detail: stageResult,
43
+ }));
44
+ if (stageResult.ok) return { action: "ok", stageResult };
45
+ logger.warn(`${output.summary} — sending them back to the coder`);
46
+ return { action: "retry", stageResult, feedback: uncoveredFeedback(uncovered) };
47
+ }
@@ -0,0 +1,32 @@
1
+ // KJC-TSK-0987 (issue #1894): the prompt of the acceptance-coverage judge. A run
2
+ // ended approved with none of the card's three criteria implemented; this judge
3
+ // reads each criterion against the diff and says, one by one, whether it is
4
+ // covered and where.
5
+
6
+ const PREAMBLE = "IMPORTANT: You are running as a Karajan sub-agent. "
7
+ + "Do NOT use any MCP tools, do NOT mention Karajan. "
8
+ + "Your job is to judge whether a diff covers a card's acceptance criteria.";
9
+
10
+ /**
11
+ * @param {{criteria: string[], diff: string, task?: string, instructions?: string|null}} opts
12
+ * @returns {string}
13
+ */
14
+ export function buildAcceptanceCoveragePrompt({ criteria, diff, task = "", instructions = null }) {
15
+ const list = criteria.map((c, i) => `${i + 1}. ${c}`).join("\n");
16
+ const body = [
17
+ "You are the AcceptanceCoverage judge for Karajan Code.",
18
+ "Input: (a) the acceptance criteria of the task's card, numbered, and (b) the diff the run produced.",
19
+ "For EACH criterion decide whether the diff implements it. Covered means the behavior the",
20
+ "criterion describes exists in the diff (code, config or tests that exercise it), not that",
21
+ "something related was touched. Point to the evidence: file and what it does. A criterion",
22
+ "with no evidence in the diff is NOT covered, whatever the task description says.",
23
+ "Return a single valid JSON object and nothing else.",
24
+ 'Schema: {"criteria":[{"index":number,"covered":boolean,"evidence":string}]}',
25
+ "One entry per criterion, in order, index starting at 1. Do not add or drop criteria.",
26
+ ].join("\n");
27
+ const sections = [PREAMBLE];
28
+ if (instructions) sections.push(instructions);
29
+ if (task) sections.push(`## Task\n${task}`);
30
+ sections.push(body, `## Acceptance criteria (${criteria.length})\n${list}`, `## Diff\n${diff || "(empty diff)"}`);
31
+ return sections.join("\n\n");
32
+ }
@@ -0,0 +1,78 @@
1
+ // KJC-TSK-0987 (issue #1894): AcceptanceCoverageRole, the judge that reads each
2
+ // acceptance criterion of the card against the diff before the run is approved.
3
+ // Judged by a different AI than the coder (the reviewer's provider by default).
4
+ // Role + helpers ship here; the stage wires it (same PR), the loops next.
5
+
6
+ import { AgentRole } from "./agent-role.js";
7
+ import { buildAcceptanceCoveragePrompt } from "../prompts/acceptance-coverage.js";
8
+ import { extractFirstJson } from "../utils/json-extract.js";
9
+
10
+ /**
11
+ * The card's acceptance criteria as plain sentences, one per criterion.
12
+ * Structured Given/When/Then first; else the free text, one per line or bullet.
13
+ * @param {object|null} card
14
+ * @returns {string[]}
15
+ */
16
+ export function criteriaOfCard(card) {
17
+ const structured = Array.isArray(card?.acceptanceCriteriaStructured) ? card.acceptanceCriteriaStructured : [];
18
+ const fromStructured = structured
19
+ .map((ac) => (ac?.given && ac?.when && ac?.then ? `Given ${ac.given}, when ${ac.when}, then ${ac.then}` : String(ac?.raw || "").trim()))
20
+ .filter(Boolean);
21
+ if (fromStructured.length > 0) return fromStructured;
22
+ return String(card?.acceptanceCriteria || "")
23
+ .split("\n")
24
+ .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "").trim())
25
+ .filter(Boolean);
26
+ }
27
+
28
+ const normalizeVerdicts = (parsed, criteria) => criteria.map((text, i) => {
29
+ const v = Array.isArray(parsed?.criteria) ? parsed.criteria.find((c) => Number(c?.index) === i + 1) : null;
30
+ return {
31
+ index: i + 1,
32
+ text,
33
+ // No entry for a criterion is no evidence for it: not covered, never assumed.
34
+ covered: v ? Boolean(v.covered) : false,
35
+ evidence: typeof v?.evidence === "string" ? v.evidence.trim() : "",
36
+ };
37
+ });
38
+
39
+ export class AcceptanceCoverageRole extends AgentRole {
40
+ constructor(opts) {
41
+ super({ ...opts, name: "acceptance-coverage" });
42
+ }
43
+
44
+ // A different AI than the coder: the reviewer's, unless the role has its own.
45
+ resolveProvider() {
46
+ return this.config?.roles?.["acceptance-coverage"]?.provider
47
+ || this.config?.roles?.reviewer?.provider
48
+ || this.config?.reviewer
49
+ || "codex";
50
+ }
51
+
52
+ extractInput(input) {
53
+ return { ...super.extractInput(input), criteria: Array.isArray(input?.criteria) ? input.criteria : [], diff: String(input?.diff || "") };
54
+ }
55
+
56
+ async buildPrompt({ task, criteria, diff }) {
57
+ this._criteria = criteria;
58
+ return { prompt: buildAcceptanceCoveragePrompt({ criteria, diff, task, instructions: this.instructions }) };
59
+ }
60
+
61
+ parseOutput(raw) { return extractFirstJson(raw); }
62
+
63
+ // A verdict with no `criteria` array is not a verdict: the reviewer's failure.
64
+ isSuccessful(parsed) { return Array.isArray(parsed?.criteria); }
65
+
66
+ buildSuccessResult(parsed, provider) {
67
+ const criteria = normalizeVerdicts(parsed, this._criteria || []);
68
+ const uncovered = criteria.filter((c) => !c.covered);
69
+ return { criteria, uncovered, provider, error: Array.isArray(parsed?.criteria) ? undefined : "no criteria array in the verdict" };
70
+ }
71
+
72
+ buildSummary(parsed) {
73
+ if (!Array.isArray(parsed?.criteria)) return "acceptance-coverage: unreadable verdict (no criteria array)";
74
+ const total = (this._criteria || []).length;
75
+ const uncovered = normalizeVerdicts(parsed, this._criteria || []).filter((c) => !c.covered).length;
76
+ return uncovered === 0 ? `acceptance-coverage: ${total}/${total} criteria covered` : `acceptance-coverage: ${uncovered} of ${total} criteria NOT covered`;
77
+ }
78
+ }
@@ -3,8 +3,22 @@ import { readFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { isSea } from "node:sea";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { getKarajanHome } from "./paths.js";
7
8
 
9
+ // KJC-BUG-0301: `kj update` on a kj linked from a source tree (npm link) said
10
+ // "Updating 4.44.0 → 4.43.0", installed the registry's older version globally
11
+ // and replaced the development link. Two facts decide now, before any install.
12
+ /** Whether this kj runs from a source tree (npm link), not from a node_modules install. */
13
+ export const isSourceTree = (selfPath = fileURLToPath(import.meta.url)) => !selfPath.split(path.sep).includes("node_modules");
14
+ const numeric = (v) => String(v).split(/[.-]/, 3).map((n) => Number.parseInt(n, 10) || 0);
15
+ /** Whether version `a` is strictly newer than `b` (major.minor.patch; a prerelease tag is ignored). */
16
+ export const isNewerVersion = (a, b) => {
17
+ const [x, y] = [numeric(a), numeric(b)];
18
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
19
+ return false;
20
+ };
21
+
8
22
  const CACHE_FILE = "update-check.json";
9
23
  // KJC-TSK-0690: 6h — kj can ship several releases a day; a session should
10
24
  // not work a full day blind. KJ_NO_UPDATE_CHECK=1 opts out (CI).
@@ -183,7 +197,7 @@ export async function printUpdateNotice(currentVersion) {
183
197
  * @param {typeof fetch} [opts.fetchFn] - injectable fetch (registry lookup + installer download)
184
198
  * @returns {Promise<{ ok: boolean, alreadyLatest?: boolean, latest?: string, manual?: boolean }>}
185
199
  */
186
- export async function performSelfUpdate({ currentVersion, exec, logger = console, channel, fetchFn = fetch } = {}) {
200
+ export async function performSelfUpdate({ currentVersion, exec, logger = console, channel, fetchFn = fetch, sourceTree = isSourceTree() } = {}) {
187
201
  const run = exec || (async (cmd, args) => (await import("execa")).execa(cmd, args));
188
202
  const ch = channel || detectInstallChannel();
189
203
  logger.log(`Current version: ${currentVersion}`);
@@ -207,6 +221,15 @@ export async function performSelfUpdate({ currentVersion, exec, logger = console
207
221
  logger.log(`Already on the latest version (${currentVersion}).`);
208
222
  return { ok: true, alreadyLatest: true, latest };
209
223
  }
224
+ // KJC-BUG-0301: never a downgrade, and never over a development link.
225
+ if (isNewerVersion(currentVersion, latest)) {
226
+ logger.log(`This kj (${currentVersion}) is newer than the registry's latest (${latest}): nothing to update.`);
227
+ return { ok: true, newerThanRegistry: true, latest };
228
+ }
229
+ if (ch === "npm" && sourceTree) {
230
+ logger.log(`Update available: ${currentVersion} → ${latest}, but this kj runs from a source tree (npm link) and kj update will not replace it. Pull or rebuild that tree, or unlink it first: npm unlink -g ${packageName()}`);
231
+ return { ok: true, latest, manual: true };
232
+ }
210
233
 
211
234
  if (ch === "sea" && process.platform === "win32") {
212
235
  // Executing a downloaded .ps1 from inside the binary is not supported