karajan-code 1.41.0 → 1.42.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": "1.41.0",
3
+ "version": "1.42.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",
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFile, execFileSync } from "node:child_process";
4
+ import { getKarajanHome } from "../utils/paths.js";
5
+
6
+ const SOURCE_EXTENSIONS = new Set([
7
+ ".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".tsx", ".jsx",
8
+ ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift", ".c", ".cpp", ".h",
9
+ ".vue", ".svelte", ".astro", ".php", ".cs", ".sh"
10
+ ]);
11
+
12
+ const EXCLUDE_DIRS = new Set([
13
+ "node_modules", ".git", "dist", "build", "coverage", ".next",
14
+ ".nuxt", ".output", "__pycache__", ".cache", ".parcel-cache"
15
+ ]);
16
+
17
+ function slugify(projectDir) {
18
+ return path.basename(projectDir).replace(/[^a-zA-Z0-9_-]/g, "_");
19
+ }
20
+
21
+ function walkSourceFiles(dir) {
22
+ // Use git ls-files for speed (instant vs recursive walk)
23
+ try {
24
+ const output = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { cwd: dir, encoding: "utf8", timeout: 5000 });
25
+ return output.trim().split("\n")
26
+ .filter(f => f && SOURCE_EXTENSIONS.has(path.extname(f)) && !EXCLUDE_DIRS.has(f.split("/")[0]))
27
+ .map(f => path.join(dir, f));
28
+ } catch {
29
+ // Fallback to recursive walk if not a git repo
30
+ const files = [];
31
+ let entries;
32
+ try {
33
+ entries = fs.readdirSync(dir, { withFileTypes: true });
34
+ } catch {
35
+ return files;
36
+ }
37
+ for (const entry of entries) {
38
+ if (EXCLUDE_DIRS.has(entry.name)) continue;
39
+ const full = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ files.push(...walkSourceFiles(full));
42
+ } else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
43
+ files.push(full);
44
+ }
45
+ }
46
+ return files;
47
+ }
48
+ }
49
+
50
+ function countLines(filePath) {
51
+ try {
52
+ const content = fs.readFileSync(filePath, "utf8");
53
+ return content.split("\n").length;
54
+ } catch {
55
+ return 0;
56
+ }
57
+ }
58
+
59
+ function countDependencies(projectDir) {
60
+ const pkgPath = path.join(projectDir, "package.json");
61
+ try {
62
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
63
+ const deps = Object.keys(pkg.dependencies || {});
64
+ const devDeps = Object.keys(pkg.devDependencies || {});
65
+ return { dependencies: deps.length, devDependencies: devDeps.length, total: deps.length + devDeps.length };
66
+ } catch {
67
+ return { dependencies: 0, devDependencies: 0, total: 0 };
68
+ }
69
+ }
70
+
71
+ function runDepcheck(projectDir) {
72
+ return new Promise((resolve) => {
73
+ execFile("npx", ["depcheck", "--json"], { cwd: projectDir, timeout: 30000 }, (err, stdout) => {
74
+ if (err && !stdout) {
75
+ resolve({ unused: [], note: "depcheck not available or failed" });
76
+ return;
77
+ }
78
+ try {
79
+ const result = JSON.parse(stdout);
80
+ const unused = [
81
+ ...(result.dependencies || []),
82
+ ...(result.devDependencies || [])
83
+ ];
84
+ resolve({ unused });
85
+ } catch {
86
+ resolve({ unused: [], note: "depcheck output not parseable" });
87
+ }
88
+ });
89
+ });
90
+ }
91
+
92
+ function findDeadExports(sourceFiles) {
93
+ const exportMap = new Map(); // file -> [exportName]
94
+ const importedNames = new Set();
95
+
96
+ const exportRe = /export\s+(?:default\s+)?(?:function|class|const|let|var|async\s+function)\s+(\w+)/g;
97
+ const namedExportRe = /export\s*\{([^}]+)\}/g;
98
+ const importRe = /import\s+\{([^}]+)\}\s+from/g;
99
+ const defaultImportRe = /import\s+(\w+)\s+from/g;
100
+
101
+ for (const file of sourceFiles) {
102
+ let content;
103
+ try {
104
+ content = fs.readFileSync(file, "utf8");
105
+ } catch {
106
+ continue;
107
+ }
108
+
109
+ const exports = [];
110
+ for (const match of content.matchAll(exportRe)) {
111
+ exports.push(match[1]);
112
+ }
113
+ for (const match of content.matchAll(namedExportRe)) {
114
+ const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/).pop().trim());
115
+ exports.push(...names);
116
+ }
117
+ if (exports.length > 0) {
118
+ exportMap.set(file, exports);
119
+ }
120
+
121
+ for (const match of content.matchAll(importRe)) {
122
+ const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/)[0].trim());
123
+ for (const name of names) importedNames.add(name);
124
+ }
125
+ for (const match of content.matchAll(defaultImportRe)) {
126
+ importedNames.add(match[1]);
127
+ }
128
+ }
129
+
130
+ const dead = [];
131
+ for (const [file, exports] of exportMap) {
132
+ for (const name of exports) {
133
+ if (!importedNames.has(name)) {
134
+ dead.push({ file, name });
135
+ }
136
+ }
137
+ }
138
+ return dead;
139
+ }
140
+
141
+ export async function measureBasalCost(projectDir) {
142
+ const sourceFiles = walkSourceFiles(projectDir);
143
+ let totalLines = 0;
144
+ for (const f of sourceFiles) {
145
+ totalLines += countLines(f);
146
+ }
147
+
148
+ const depInfo = countDependencies(projectDir);
149
+ const depcheck = await runDepcheck(projectDir);
150
+ const deadExports = findDeadExports(sourceFiles);
151
+
152
+ return {
153
+ totalLines,
154
+ totalFiles: sourceFiles.length,
155
+ dependencies: depInfo,
156
+ unusedDependencies: depcheck,
157
+ deadExports
158
+ };
159
+ }
160
+
161
+ function auditDir() {
162
+ return path.join(getKarajanHome(), "audits");
163
+ }
164
+
165
+ export function loadPreviousAudit(projectDir) {
166
+ const file = path.join(auditDir(), slugify(projectDir), "latest.json");
167
+ try {
168
+ return JSON.parse(fs.readFileSync(file, "utf8"));
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ export function saveAuditSnapshot(projectDir, metrics) {
175
+ const dir = path.join(auditDir(), slugify(projectDir));
176
+ fs.mkdirSync(dir, { recursive: true });
177
+ const snapshot = { ...metrics, timestamp: new Date().toISOString() };
178
+ fs.writeFileSync(path.join(dir, "latest.json"), JSON.stringify(snapshot, null, 2));
179
+ return snapshot;
180
+ }
181
+
182
+ export function computeGrowthDelta(current, previous) {
183
+ if (!previous) return null;
184
+ return {
185
+ lines: current.totalLines - previous.totalLines,
186
+ files: current.totalFiles - previous.totalFiles,
187
+ deps: current.dependencies.total - (previous.dependencies?.total ?? 0),
188
+ since: previous.timestamp || null
189
+ };
190
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Lazy HU Planner — refines HUs one at a time using context from completed HUs.
3
+ *
4
+ * Instead of fully planning all HUs upfront, only the first HU is decomposed
5
+ * with full acceptance criteria. Subsequent HUs are refined lazily before
6
+ * execution, incorporating learnings from previously completed HUs.
7
+ */
8
+ import { createAgent } from "../agents/index.js";
9
+ import { extractFirstJson } from "../utils/json-extract.js";
10
+
11
+ const SUBAGENT_PREAMBLE = [
12
+ "IMPORTANT: You are running as a Karajan sub-agent.",
13
+ "Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
14
+ "Do NOT use any MCP tools. Focus only on refining the user story."
15
+ ].join(" ");
16
+
17
+ /**
18
+ * Build a prompt asking the AI to refine an HU based on context from completed HUs.
19
+ * @param {object} hu - The HU to refine (with at least id, original.text or certified.text).
20
+ * @param {Array<object>} completedHus - Array of completed HU results with summaries.
21
+ * @returns {string} The assembled refinement prompt.
22
+ */
23
+ export function buildRefinementPrompt(hu, completedHus) {
24
+ const sections = [SUBAGENT_PREAMBLE];
25
+
26
+ sections.push("## HU Refinement");
27
+ sections.push(
28
+ "You are a senior product owner. Refine the following user story by adding or updating its acceptance criteria.",
29
+ "Use the context from previously completed HUs to make the criteria more precise and avoid redundant work."
30
+ );
31
+
32
+ if (completedHus.length > 0) {
33
+ sections.push("## Previously Completed HUs");
34
+ for (const completed of completedHus) {
35
+ const title = completed.certified?.title || completed.id;
36
+ const summary = completed.resultSummary || completed.certified?.text || completed.original?.text || "No summary available";
37
+ sections.push(`### ${completed.id} — ${title}\nResult: ${summary}`);
38
+ }
39
+ }
40
+
41
+ const huText = hu.certified?.text || hu.original?.text || `Implement HU ${hu.id}`;
42
+ sections.push(`## HU to Refine\n### ${hu.id}\n${huText}`);
43
+
44
+ sections.push(
45
+ "Return a single valid JSON object and nothing else.",
46
+ `JSON schema: {"id":string,"title":string,"text":string,"acceptanceCriteria":[string]}`,
47
+ "Rules:",
48
+ "- Keep the same HU id",
49
+ "- Refine the description to be more specific based on what was learned",
50
+ "- Add concrete, testable acceptance criteria",
51
+ "- Avoid repeating work already done in completed HUs",
52
+ "- The text field should be the full story description ready for implementation"
53
+ );
54
+
55
+ return sections.join("\n\n");
56
+ }
57
+
58
+ /**
59
+ * Refine an HU using AI, incorporating context from completed HUs.
60
+ * @param {object} hu - The HU story object to refine.
61
+ * @param {Array<object>} completedHus - Completed HU stories with results.
62
+ * @param {object} config - Pipeline configuration.
63
+ * @param {object} logger - Logger instance.
64
+ * @returns {Promise<object>} Updated HU object with refined description and AC.
65
+ */
66
+ export async function refineHuWithContext(hu, completedHus, config, logger) {
67
+ const provider = config?.roles?.hu_reviewer?.provider || config?.roles?.coder?.provider || "claude";
68
+ const prompt = buildRefinementPrompt(hu, completedHus);
69
+
70
+ logger.info(`Lazy planner: refining HU ${hu.id} with context from ${completedHus.length} completed HU(s)`);
71
+
72
+ const agent = createAgent(provider, config, logger);
73
+ let result;
74
+ try {
75
+ result = await agent.runTask({ prompt, role: "hu-reviewer" });
76
+ } catch (err) {
77
+ logger.warn(`Lazy planner: refinement failed for HU ${hu.id}: ${err.message}`);
78
+ return hu;
79
+ }
80
+
81
+ if (!result.ok) {
82
+ logger.warn(`Lazy planner: refinement returned not-ok for HU ${hu.id}`);
83
+ return hu;
84
+ }
85
+
86
+ const parsed = extractFirstJson(result.output);
87
+ if (!parsed || !parsed.text) {
88
+ logger.warn(`Lazy planner: could not parse refinement output for HU ${hu.id}`);
89
+ return hu;
90
+ }
91
+
92
+ // Update the HU with refined content
93
+ const refined = { ...hu };
94
+ refined.certified = {
95
+ ...(hu.certified || {}),
96
+ text: parsed.text,
97
+ title: parsed.title || hu.certified?.title || hu.id,
98
+ acceptanceCriteria: parsed.acceptanceCriteria || []
99
+ };
100
+ refined.needsRefinement = false;
101
+
102
+ logger.info(`Lazy planner: HU ${hu.id} refined successfully`);
103
+ return refined;
104
+ }
@@ -5,6 +5,7 @@
5
5
  import { topologicalSort } from "../hu/graph.js";
6
6
  import { updateStoryStatus, loadHuBatch, saveHuBatch, HU_STATUS } from "../hu/store.js";
7
7
  import { emitProgress, makeEvent } from "../utils/events.js";
8
+ import { refineHuWithContext } from "../hu/lazy-planner.js";
8
9
 
9
10
  /**
10
11
  * Determine whether the HU reviewer result requires a sub-pipeline
@@ -72,7 +73,7 @@ function buildHuTask(story) {
72
73
  * @param {object} params.logger - Logger instance
73
74
  * @returns {Promise<{approved: boolean, results: object[], blockedIds: string[]}>}
74
75
  */
75
- export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger }) {
76
+ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger, config = null }) {
76
77
  const batchSessionId = huReviewerResult.batchSessionId;
77
78
  let batch;
78
79
  try {
@@ -108,6 +109,40 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
108
109
  continue;
109
110
  }
110
111
 
112
+ // --- Lazy refinement: refine HU if it needs it, using completed HU context ---
113
+ if (story.needsRefinement && config) {
114
+ const completedHus = results
115
+ .filter(r => r.approved)
116
+ .map(r => {
117
+ const completedStory = batch.stories.find(s => s.id === r.huId);
118
+ return {
119
+ ...completedStory,
120
+ resultSummary: r.result?.summary || r.result?.reason || null
121
+ };
122
+ });
123
+
124
+ emitProgress(emitter, makeEvent("hu:refine-start", { ...eventBase, stage: "hu-sub-pipeline" }, {
125
+ message: `Refining HU ${story.id} with context from ${completedHus.length} completed HU(s)`,
126
+ detail: { huId: story.id, completedCount: completedHus.length }
127
+ }));
128
+
129
+ try {
130
+ const refined = await refineHuWithContext(story, completedHus, config, logger);
131
+ // Apply refinement to the batch story in-place
132
+ Object.assign(story, refined);
133
+ story.needsRefinement = false;
134
+ await saveHuBatch(batchSessionId, batch);
135
+
136
+ emitProgress(emitter, makeEvent("hu:refine-end", { ...eventBase, stage: "hu-sub-pipeline" }, {
137
+ message: `HU ${story.id} refined successfully`,
138
+ detail: { huId: story.id }
139
+ }));
140
+ } catch (err) {
141
+ logger.warn(`Lazy refinement failed for HU ${story.id}: ${err.message} — proceeding with original`);
142
+ story.needsRefinement = false;
143
+ }
144
+ }
145
+
111
146
  const huTask = buildHuTask(story);
112
147
 
113
148
  // --- hu:start ---
@@ -646,11 +646,17 @@ export async function runHuReviewerStage({ config, logger, emitter, eventBase, s
646
646
  config, logger, emitter, eventBase, session, coderRole, trackBudget
647
647
  });
648
648
  if (decomposed && decomposed.length > 1) {
649
- stories = decomposed.map(hu => ({
650
- id: hu.id,
651
- text: `As a ${hu.role}, I want to ${hu.goal}, so that ${hu.benefit}\n\nTitle: ${hu.title}\nAcceptance Criteria:\n${hu.acceptanceCriteria.map(ac => `- ${ac}`).join("\n")}`,
652
- blocked_by: hu.dependsOn || []
653
- }));
649
+ stories = decomposed.map((hu, idx) => {
650
+ const isFirst = idx === 0;
651
+ const fullText = `As a ${hu.role}, I want to ${hu.goal}, so that ${hu.benefit}\n\nTitle: ${hu.title}\nAcceptance Criteria:\n${hu.acceptanceCriteria.map(ac => `- ${ac}`).join("\n")}`;
652
+ const skeletonText = `As a ${hu.role}, I want to ${hu.goal}, so that ${hu.benefit}\n\nTitle: ${hu.title}`;
653
+ return {
654
+ id: hu.id,
655
+ text: isFirst ? fullText : skeletonText,
656
+ blocked_by: hu.dependsOn || [],
657
+ needsRefinement: !isFirst
658
+ };
659
+ });
654
660
  } else {
655
661
  stories = [{ id: "HU-AUTO-001", text: session.task }];
656
662
  }
@@ -1426,7 +1426,8 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1426
1426
  runIterationFn: async (huTask) => runIterationLoop(ctx, { task: huTask, askQuestion, emitter, logger }),
1427
1427
  emitter,
1428
1428
  eventBase: ctx.eventBase,
1429
- logger
1429
+ logger,
1430
+ config: ctx.config
1430
1431
  });
1431
1432
 
1432
1433
  emitProgress(emitter, makeEvent("hu:sub-pipeline:end", { ...ctx.eventBase, stage: "hu-sub-pipeline" }, {
@@ -14,7 +14,7 @@ const VALID_SCORES = new Set(["A", "B", "C", "D", "F"]);
14
14
  const VALID_SEVERITIES = new Set(["critical", "high", "medium", "low"]);
15
15
  const VALID_IMPACT = new Set(["high", "medium", "low"]);
16
16
 
17
- export function buildAuditPrompt({ task, instructions, dimensions = null, context = null }) {
17
+ export function buildAuditPrompt({ task, instructions, dimensions = null, context = null, basalCost = null, growthDelta = null }) {
18
18
  const sections = [SUBAGENT_PREAMBLE];
19
19
 
20
20
  if (instructions) {
@@ -112,6 +112,40 @@ export function buildAuditPrompt({ task, instructions, dimensions = null, contex
112
112
  `Only include dimensions you were asked to analyze: ${activeDimensions.join(", ")}`
113
113
  );
114
114
 
115
+ if (basalCost) {
116
+ const lines = [
117
+ "## Basal Cost",
118
+ `- Total source lines: ${basalCost.totalLines}`,
119
+ `- Total source files: ${basalCost.totalFiles}`,
120
+ `- Dependencies: ${basalCost.dependencies?.total ?? 0} (${basalCost.dependencies?.dependencies ?? 0} prod + ${basalCost.dependencies?.devDependencies ?? 0} dev)`
121
+ ];
122
+ if (basalCost.unusedDependencies?.unused?.length > 0) {
123
+ lines.push(`- Unused dependencies: ${basalCost.unusedDependencies.unused.join(", ")}`);
124
+ } else if (basalCost.unusedDependencies?.note) {
125
+ lines.push(`- Unused dependencies: ${basalCost.unusedDependencies.note}`);
126
+ }
127
+ if (basalCost.deadExports?.length > 0) {
128
+ lines.push(`- Dead exports (exported but never imported): ${basalCost.deadExports.length}`);
129
+ for (const de of basalCost.deadExports.slice(0, 20)) {
130
+ lines.push(` - ${de.name} in ${de.file}`);
131
+ }
132
+ if (basalCost.deadExports.length > 20) {
133
+ lines.push(` - ... and ${basalCost.deadExports.length - 20} more`);
134
+ }
135
+ }
136
+ if (growthDelta) {
137
+ lines.push("");
138
+ lines.push("### Growth since last audit");
139
+ lines.push(`- Lines: ${growthDelta.lines >= 0 ? "+" : ""}${growthDelta.lines}`);
140
+ lines.push(`- Files: ${growthDelta.files >= 0 ? "+" : ""}${growthDelta.files}`);
141
+ lines.push(`- Dependencies: ${growthDelta.deps >= 0 ? "+" : ""}${growthDelta.deps}`);
142
+ if (growthDelta.since) lines.push(`- Since: ${growthDelta.since}`);
143
+ }
144
+ lines.push("");
145
+ lines.push("Flag if basal cost is growing faster than feature delivery. Recommend elimination of unused code and dependencies.");
146
+ sections.push(lines.join("\n"));
147
+ }
148
+
115
149
  if (context) {
116
150
  sections.push(`## Context\n${context}`);
117
151
  }
@@ -1,6 +1,7 @@
1
1
  import { BaseRole } from "./base-role.js";
2
2
  import { createAgent as defaultCreateAgent } from "../agents/index.js";
3
3
  import { buildAuditPrompt, parseAuditOutput, AUDIT_DIMENSIONS } from "../prompts/audit.js";
4
+ import { measureBasalCost, loadPreviousAudit, saveAuditSnapshot, computeGrowthDelta } from "../audit/basal-cost.js";
4
5
 
5
6
  function resolveProvider(config) {
6
7
  return (
@@ -57,7 +58,18 @@ export class AuditRole extends BaseRole {
57
58
  const provider = resolveProvider(this.config);
58
59
  const agent = this._createAgent(provider, this.config, this.logger);
59
60
 
60
- const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context });
61
+ const projectDir = this.config?.projectDir || process.cwd();
62
+ let basalCost = null;
63
+ let growthDelta = null;
64
+ try {
65
+ basalCost = await measureBasalCost(projectDir);
66
+ const previous = loadPreviousAudit(projectDir);
67
+ growthDelta = computeGrowthDelta(basalCost, previous);
68
+ } catch {
69
+ // basal cost is best-effort, don't fail the audit
70
+ }
71
+
72
+ const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context, basalCost, growthDelta });
61
73
  const runArgs = { prompt, role: "audit" };
62
74
  if (onOutput) runArgs.onOutput = onOutput;
63
75
  const result = await agent.runTask(runArgs);
@@ -82,12 +94,18 @@ export class AuditRole extends BaseRole {
82
94
  };
83
95
  }
84
96
 
97
+ if (basalCost) {
98
+ try { saveAuditSnapshot(projectDir, basalCost); } catch { /* best-effort */ }
99
+ }
100
+
85
101
  return {
86
102
  ok: true,
87
103
  result: {
88
104
  summary: parsed.summary,
89
105
  dimensions: parsed.dimensions,
90
106
  topRecommendations: parsed.topRecommendations,
107
+ basalCost: basalCost || undefined,
108
+ growthDelta: growthDelta || undefined,
91
109
  provider
92
110
  },
93
111
  summary: buildSummary(parsed),