karajan-code 2.0.2 → 2.1.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": "2.0.2",
3
+ "version": "2.1.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",
package/src/config.js CHANGED
@@ -45,6 +45,7 @@ const DEFAULTS = {
45
45
  },
46
46
  review_mode: "standard",
47
47
  max_iterations: 5,
48
+ hu_max_iterations: 3,
48
49
  max_budget_usd: null,
49
50
  review_rules: "./.karajan/review-rules.md",
50
51
  coder_rules: "./.karajan/coder-rules.md",
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Per-HU git automation: branch, commit, push, PR per HU story.
3
+ * Called from the HU sub-pipeline wrapper in orchestrator.js.
4
+ */
5
+ import { commitAll, pushBranch, createPullRequest, hasChanges } from "../utils/git.js";
6
+ import { runCommand } from "../utils/process.js";
7
+
8
+ /**
9
+ * Build a git-safe branch name for an HU story.
10
+ * @param {string} prefix - e.g. "feat/"
11
+ * @param {object} story - HU story {id, title}
12
+ * @returns {string}
13
+ */
14
+ export function buildHuBranchName(prefix, story) {
15
+ const baseSlug = String(story.title || story.id || "hu")
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "")
19
+ .slice(0, 40);
20
+ return `${prefix}${story.id}-${baseSlug}`;
21
+ }
22
+
23
+ /**
24
+ * Resolve the base branch for a given HU based on its dependencies.
25
+ * If it has no blocked_by, uses config.base_branch.
26
+ * If it has parents, uses the last-created parent branch (assumes parents ran first).
27
+ *
28
+ * @param {object} story - the HU story
29
+ * @param {Map<string,string>} huBranches - map of huId → branchName (already created)
30
+ * @param {string} baseBranch - config.base_branch fallback
31
+ * @returns {string} base branch name
32
+ */
33
+ export function resolveHuBase(story, huBranches, baseBranch) {
34
+ const parents = story.blocked_by || [];
35
+ if (parents.length === 0) return baseBranch;
36
+ // Walk parents in reverse order of declaration to find the most recent one
37
+ for (let i = parents.length - 1; i >= 0; i--) {
38
+ const parentBranch = huBranches.get(parents[i]);
39
+ if (parentBranch) return parentBranch;
40
+ }
41
+ return baseBranch;
42
+ }
43
+
44
+ /**
45
+ * Create a branch for an HU starting from its resolved base.
46
+ * Returns the branch name created (or null if git automation is disabled).
47
+ *
48
+ * @param {object} params
49
+ * @param {object} params.story
50
+ * @param {Map} params.huBranches
51
+ * @param {object} params.config
52
+ * @param {object} params.logger
53
+ * @returns {Promise<string|null>}
54
+ */
55
+ export async function prepareHuBranch({ story, huBranches, config, logger }) {
56
+ if (!config.git?.auto_commit && !config.git?.auto_push && !config.git?.auto_pr) {
57
+ return null;
58
+ }
59
+ const baseBranch = resolveHuBase(story, huBranches, config.base_branch || "main");
60
+ const prefix = config.git?.branch_prefix || "feat/";
61
+ const branchName = buildHuBranchName(prefix, story);
62
+
63
+ // Checkout from the resolved base. Use `git checkout -B` to overwrite if rerun.
64
+ const res = await runCommand("git", ["checkout", "-B", branchName, baseBranch], {});
65
+ if (res.exitCode !== 0) {
66
+ logger.warn(`HU git: failed to create branch ${branchName} from ${baseBranch}: ${res.stderr}`);
67
+ return null;
68
+ }
69
+ huBranches.set(story.id, branchName);
70
+ logger.info(`HU ${story.id}: branch '${branchName}' from '${baseBranch}'`);
71
+ return branchName;
72
+ }
73
+
74
+ /**
75
+ * After an HU is approved, commit its changes and optionally push + create PR.
76
+ *
77
+ * @param {object} params
78
+ * @param {object} params.story
79
+ * @param {string} params.branchName
80
+ * @param {object} params.config
81
+ * @param {object} params.logger
82
+ * @returns {Promise<{committed: boolean, pushed: boolean, prUrl: string|null}>}
83
+ */
84
+ export async function finalizeHuCommit({ story, branchName, config, logger }) {
85
+ const result = { committed: false, pushed: false, prUrl: null };
86
+ if (!branchName) return result;
87
+
88
+ const changed = await hasChanges();
89
+ if (!changed) {
90
+ logger.info(`HU ${story.id}: no changes to commit`);
91
+ return result;
92
+ }
93
+
94
+ const title = story.title || story.id;
95
+ const commitMsg = `feat(${story.id}): ${title}`;
96
+ if (config.git?.auto_commit) {
97
+ const commitRes = await commitAll(commitMsg);
98
+ if (commitRes) {
99
+ result.committed = true;
100
+ logger.info(`HU ${story.id}: committed on '${branchName}'`);
101
+ }
102
+ }
103
+
104
+ if (config.git?.auto_push && result.committed) {
105
+ try {
106
+ await pushBranch(branchName);
107
+ result.pushed = true;
108
+ logger.info(`HU ${story.id}: pushed '${branchName}'`);
109
+ } catch (err) {
110
+ logger.warn(`HU ${story.id}: push failed: ${err.message}`);
111
+ }
112
+ }
113
+
114
+ if (config.git?.auto_pr && result.pushed) {
115
+ try {
116
+ const prBody = [
117
+ `## HU ${story.id}: ${title}`,
118
+ "",
119
+ story.certified?.text || "",
120
+ "",
121
+ "### Acceptance criteria",
122
+ ...(story.acceptance_criteria || []).map(c => `- ${c}`)
123
+ ].join("\n");
124
+ const url = await createPullRequest({
125
+ baseBranch: config.base_branch || "main",
126
+ branch: branchName,
127
+ title: commitMsg,
128
+ body: prBody
129
+ });
130
+ result.prUrl = url;
131
+ logger.info(`HU ${story.id}: PR created ${url}`);
132
+ } catch (err) {
133
+ logger.warn(`HU ${story.id}: PR creation failed: ${err.message}`);
134
+ }
135
+ }
136
+
137
+ return result;
138
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * HU Auto-Generator — converts triage subtasks (+ researcher/architect context)
3
+ * into a certified HU batch ready for hu-sub-pipeline execution.
4
+ *
5
+ * Input: original task, triage subtasks, detected stack, researcher/architect context.
6
+ * Output: HU batch with setup HU (when needed), task HUs with per-HU task_type,
7
+ * and a dependency graph (setup blocks everything; remaining linear by default).
8
+ */
9
+
10
+ /**
11
+ * Classify a subtask into a Karajan task_type.
12
+ * Maps free-text subtask descriptions to {infra|sw|add-tests|doc|refactor|nocode}.
13
+ */
14
+ export function classifyTaskType(text) {
15
+ if (!text || typeof text !== "string") return "sw";
16
+ const t = text.toLowerCase();
17
+ // Order matters: no-code beats infra (Zapier/Notion setups are no-code, not infra)
18
+ if (/\b(no-code|nocode|zapier|make\.com|airtable|notion)\b/.test(t)) return "nocode";
19
+ if (/\b(setup|install|init(?:ialize|iate)?|configure|scaffold|bootstrap)\b/.test(t)) return "infra";
20
+ if (/\b(docker|ci\/cd|pipeline|deploy|workflow\.yml|github actions?)\b/.test(t)) return "infra";
21
+ if (/\b(tests?|coverage|spec|vitest|jest|mocha|playwright)\b/.test(t) && !/\b(component|feature|endpoint)\b/.test(t)) return "add-tests";
22
+ if (/\b(readme|docs?|documentation|guide|tutorial)\b/.test(t)) return "doc";
23
+ if (/\b(refactor|cleanup|reorganiz|restructure|extract)\b/.test(t)) return "refactor";
24
+ return "sw";
25
+ }
26
+
27
+ /**
28
+ * Decide whether a setup HU is needed.
29
+ * Needed when: project is new OR stack hints suggest new dependencies.
30
+ */
31
+ export function needsSetupHu({ isNewProject = false, stackHints = [], subtasks = [] }) {
32
+ if (isNewProject) return true;
33
+ if (stackHints.length > 0) return true;
34
+ // Subtasks mentioning a framework/tool suggest fresh setup
35
+ const setupKeywords = /\b(npm init|package\.json|workspace|monorepo|vite|vitest|express|astro|next\.js|nestjs)\b/i;
36
+ return subtasks.some(s => setupKeywords.test(s));
37
+ }
38
+
39
+ /**
40
+ * Build the setup HU story from stack hints + subtasks.
41
+ */
42
+ function buildSetupHu({ stackHints, subtasks, originalTask }) {
43
+ const hintList = stackHints.length > 0
44
+ ? stackHints.map(h => `- ${h}`).join("\n")
45
+ : "- Detect required dependencies from task and install them";
46
+ const certifiedText = [
47
+ `**Setup project infrastructure and dependencies.**`,
48
+ ``,
49
+ `Original goal: ${originalTask}`,
50
+ ``,
51
+ `**Scope:**`,
52
+ `- Initialize project structure (package.json, workspaces if monorepo)`,
53
+ `- Install all dependencies required by the task`,
54
+ `- Configure tooling (test framework, linter, build tool)`,
55
+ `- Create .env.example with all required env vars`,
56
+ `- Verify install works (npm install, npm run test --run)`,
57
+ ``,
58
+ `**Stack hints:**`,
59
+ hintList
60
+ ].join("\n");
61
+ return {
62
+ id: "HU-01",
63
+ title: "Setup project infrastructure",
64
+ task_type: "infra",
65
+ status: "certified",
66
+ blocked_by: [],
67
+ certified: { text: certifiedText },
68
+ acceptance_criteria: [
69
+ "Project builds without errors (npm install succeeds)",
70
+ "Test framework is installed and 'npm test' runs (even with 0 tests)",
71
+ "All declared dependencies match what the task requires",
72
+ ".env.example exists with documented variables"
73
+ ]
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Build a task HU story from a subtask description.
79
+ */
80
+ function buildTaskHu({ id, subtask, originalTask, blockedBy }) {
81
+ const taskType = classifyTaskType(subtask);
82
+ const certifiedText = [
83
+ `**${subtask}**`,
84
+ ``,
85
+ `Part of: ${originalTask}`,
86
+ ``,
87
+ `**Scope:** implement this subtask only. Do not touch unrelated subtasks.`
88
+ ].join("\n");
89
+ return {
90
+ id,
91
+ title: subtask.length > 80 ? subtask.slice(0, 77) + "..." : subtask,
92
+ task_type: taskType,
93
+ status: "certified",
94
+ blocked_by: blockedBy,
95
+ certified: { text: certifiedText },
96
+ acceptance_criteria: [
97
+ `Subtask '${subtask}' is implemented`,
98
+ `Unit tests cover the new code (where applicable)`,
99
+ `No regressions in existing functionality`
100
+ ]
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Main entry point: generate a certified HU batch from triage output.
106
+ *
107
+ * @param {object} input
108
+ * @param {string} input.originalTask - the user's raw task
109
+ * @param {string[]} input.subtasks - triage.subtasks array
110
+ * @param {string[]} [input.stackHints] - detected stack keywords (e.g. ["nodejs", "vitest"])
111
+ * @param {boolean} [input.isNewProject] - true when projectDir is empty/fresh
112
+ * @param {string} [input.researcherContext] - researcher output (optional, used for better HU text)
113
+ * @param {string} [input.architectContext] - architect output (optional, used for dep graph)
114
+ * @returns {{ stories: object[], total: number, certified: number, generated: true }}
115
+ */
116
+ export function generateHuBatch({
117
+ originalTask,
118
+ subtasks = [],
119
+ stackHints = [],
120
+ isNewProject = false,
121
+ researcherContext = null,
122
+ architectContext = null
123
+ }) {
124
+ if (!originalTask || typeof originalTask !== "string") {
125
+ throw new Error("generateHuBatch: originalTask is required");
126
+ }
127
+ if (!Array.isArray(subtasks) || subtasks.length === 0) {
128
+ throw new Error("generateHuBatch: subtasks array is required");
129
+ }
130
+
131
+ const stories = [];
132
+ const needsSetup = needsSetupHu({ isNewProject, stackHints, subtasks });
133
+ let nextId = 1;
134
+
135
+ if (needsSetup) {
136
+ stories.push(buildSetupHu({ stackHints, subtasks, originalTask }));
137
+ nextId = 2;
138
+ }
139
+
140
+ // Task HUs: linear dependency chain after setup (conservative default).
141
+ // Architect context could later inform parallel-safe groupings.
142
+ const setupId = needsSetup ? "HU-01" : null;
143
+ let previousId = setupId;
144
+ for (const subtask of subtasks) {
145
+ const id = `HU-${String(nextId).padStart(2, "0")}`;
146
+ const blockedBy = [];
147
+ if (setupId) blockedBy.push(setupId);
148
+ // Conservative: also depend on previous task HU to enforce linear execution.
149
+ // Later phases can relax this with architect-informed graph.
150
+ if (previousId && previousId !== setupId) blockedBy.push(previousId);
151
+ stories.push(buildTaskHu({ id, subtask, originalTask, blockedBy }));
152
+ previousId = id;
153
+ nextId += 1;
154
+ }
155
+
156
+ return {
157
+ stories,
158
+ total: stories.length,
159
+ certified: stories.length,
160
+ generated: true,
161
+ source: { triage_subtasks: subtasks.length, researcher: Boolean(researcherContext), architect: Boolean(architectContext) }
162
+ };
163
+ }
@@ -120,7 +120,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
120
120
  }));
121
121
 
122
122
  try {
123
- const iterResult = await runIterationFn(huTask);
123
+ const iterResult = await runIterationFn(huTask, story);
124
124
  const approved = Boolean(iterResult?.approved);
125
125
 
126
126
  // --- Transition to reviewing (post-coder, pre-reviewer evaluation) ---
@@ -687,6 +687,11 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
687
687
  const projectDir = updatedConfig.projectDir || process.cwd();
688
688
  await updateGitignoreForStack(projectDir, { stageResults, task, logger });
689
689
 
690
+ // --- Auto-HU: when triage recommended decomposition and no manual huFile, generate HU batch ---
691
+ await maybeGenerateAutoHuBatch({
692
+ flags, stageResults, task, plannedTask, logger, emitter, eventBase, projectDir, session
693
+ });
694
+
690
695
  // --- Auto-install skills based on task + planner output + project detection ---
691
696
  // Runs AFTER triage and planner so that the planned task text (which includes
692
697
  // planner output like implementation steps) is available for keyword detection.
@@ -716,6 +721,87 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
716
721
  return { plannedTask, updatedConfig };
717
722
  }
718
723
 
724
+ /**
725
+ * Auto-generate HU batch from triage decomposition when no manual huFile is present.
726
+ * Runs after researcher/architect/planner so that context is available for better HUs.
727
+ * Sets stageResults.huReviewer so needsSubPipeline picks it up later.
728
+ */
729
+ async function maybeGenerateAutoHuBatch({ flags, stageResults, task, plannedTask, logger, emitter, eventBase, projectDir, session }) {
730
+ // Skip if user passed a manual hu-file
731
+ if (flags?.huFile) return;
732
+ // Skip if hu-reviewer already produced a batch (manual enable + PG stories)
733
+ if (stageResults.huReviewer) return;
734
+ // Need triage decomposition recommendation
735
+ const shouldDecompose = stageResults.triage?.shouldDecompose;
736
+ const subtasks = stageResults.triage?.subtasks;
737
+ if (!shouldDecompose || !Array.isArray(subtasks) || subtasks.length < 2) return;
738
+
739
+ const { generateHuBatch } = await import("./hu/auto-generator.js");
740
+
741
+ // Detect if project is new: empty dir or only .git/.karajan/.gitignore
742
+ let isNewProject = false;
743
+ try {
744
+ const fs = await import("node:fs/promises");
745
+ const entries = await fs.readdir(projectDir);
746
+ const relevant = entries.filter(e => !e.startsWith(".git") && e !== ".karajan" && e !== ".gitignore");
747
+ isNewProject = relevant.length === 0;
748
+ } catch { /* ignore */ }
749
+
750
+ // Extract stack hints from planner + architect output
751
+ const stackHints = [];
752
+ const combined = `${stageResults.planner?.plan || ""} ${stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : ""} ${task}`.toLowerCase();
753
+ const stackKeywords = ["express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "fastapi", "django", "spring", "gin", "nestjs", "monorepo", "workspaces"];
754
+ for (const kw of stackKeywords) {
755
+ if (combined.includes(kw)) stackHints.push(kw);
756
+ }
757
+
758
+ const batch = generateHuBatch({
759
+ originalTask: task,
760
+ subtasks,
761
+ stackHints,
762
+ isNewProject,
763
+ researcherContext: stageResults.researcher?.summary || null,
764
+ architectContext: stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : null
765
+ });
766
+
767
+ // Persist batch to HU store so hu-sub-pipeline can update story status via saveHuBatch.
768
+ // Use session.id as batchSessionId.
769
+ const batchSessionId = `auto-${session.id}`;
770
+ try {
771
+ const fs = await import("node:fs/promises");
772
+ const path = await import("node:path");
773
+ const { getKarajanHome } = await import("./utils/paths.js");
774
+ const huDir = path.join(getKarajanHome(), "hu", batchSessionId);
775
+ await fs.mkdir(huDir, { recursive: true });
776
+ const persistBatch = {
777
+ session_id: batchSessionId,
778
+ created_at: new Date().toISOString(),
779
+ updated_at: new Date().toISOString(),
780
+ stories: batch.stories
781
+ };
782
+ await fs.writeFile(path.join(huDir, "batch.json"), JSON.stringify(persistBatch, null, 2));
783
+ } catch (err) {
784
+ logger.warn(`Auto-HU: failed to persist batch (${err.message}) — sub-pipeline will use in-memory fallback`);
785
+ }
786
+
787
+ // Wrap in format compatible with needsSubPipeline + runHuSubPipeline
788
+ stageResults.huReviewer = {
789
+ ok: true,
790
+ stories: batch.stories,
791
+ total: batch.total,
792
+ certified: batch.certified,
793
+ batchSessionId,
794
+ auto_generated: true,
795
+ source: batch.source
796
+ };
797
+
798
+ logger.info(`Auto-HU: generated ${batch.total} stories (${batch.source.triage_subtasks} subtasks${isNewProject ? ", new project" : ""}${stackHints.length ? `, stack: ${stackHints.join(",")}` : ""})`);
799
+ emitProgress(emitter, makeEvent("hu:auto-generated", { ...eventBase, stage: "hu-auto-gen" }, {
800
+ message: `Auto-generated ${batch.total} HU(s) from triage decomposition`,
801
+ detail: { total: batch.total, subtasks: batch.source.triage_subtasks, isNewProject, stackHints }
802
+ }));
803
+ }
804
+
719
805
  async function runCoderAndRefactorerStages({ coderRoleInstance, coderRole, refactorerRole, pipelineFlags, config, logger, emitter, eventBase, session, plannedTask, trackBudget, i, brainCtx }) {
720
806
  const coderResult = await runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i, brainCtx });
721
807
  if (coderResult?.action === "pause") return { action: "return", result: coderResult.result };
@@ -1532,9 +1618,33 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1532
1618
  detail: { total: ctx.stageResults.huReviewer.total, certified: ctx.stageResults.huReviewer.certified }
1533
1619
  }));
1534
1620
 
1621
+ // Per-HU pipeline: focused max_iterations, fresh Brain state, own git branch.
1622
+ const originalMaxIterations = ctx.config.max_iterations;
1623
+ const huMaxIterations = ctx.config.hu_max_iterations ?? 3;
1624
+ const huBranches = new Map();
1625
+ const { prepareHuBranch, finalizeHuCommit } = await import("./git/hu-automation.js");
1535
1626
  const subPipelineResult = await runHuSubPipeline({
1536
1627
  huReviewerResult: ctx.stageResults.huReviewer,
1537
- runIterationFn: async (huTask) => runIterationLoop(ctx, { task: huTask, askQuestion, emitter, logger }),
1628
+ runIterationFn: async (huTask, story) => {
1629
+ ctx.config.max_iterations = huMaxIterations;
1630
+ if (ctx.brainCtx?.enabled) {
1631
+ ctx.brainCtx.extensionCount = 0;
1632
+ const { createBrainContext } = await import("./orchestrator/brain-coordinator.js");
1633
+ const fresh = createBrainContext({ enabled: true });
1634
+ ctx.brainCtx.feedbackQueue = fresh.feedbackQueue;
1635
+ ctx.brainCtx.verificationTracker = fresh.verificationTracker;
1636
+ }
1637
+ const branchName = await prepareHuBranch({ story, huBranches, config: ctx.config, logger });
1638
+ try {
1639
+ const result = await runIterationLoop(ctx, { task: huTask, askQuestion, emitter, logger });
1640
+ if (result?.approved) {
1641
+ await finalizeHuCommit({ story, branchName, config: ctx.config, logger });
1642
+ }
1643
+ return result;
1644
+ } finally {
1645
+ ctx.config.max_iterations = originalMaxIterations;
1646
+ }
1647
+ },
1538
1648
  emitter,
1539
1649
  eventBase: ctx.eventBase,
1540
1650
  logger,