karajan-code 1.45.0 → 1.46.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/README.md CHANGED
@@ -90,7 +90,7 @@ This is the primary use case. Karajan runs as an MCP server inside Claude Code,
90
90
  You → Claude Code → kj_run (via MCP) → triage → coder → sonar → reviewer → tester → security
91
91
  ```
92
92
 
93
- The MCP server auto-registers during `npm install`. Your AI agent sees 21 tools (`kj_run`, `kj_code`, `kj_review`, etc.) and uses them as needed.
93
+ The MCP server auto-registers during `npm install`. Your AI agent sees 23 tools (`kj_run`, `kj_code`, `kj_review`, etc.) and uses them as needed.
94
94
 
95
95
  **The problem**: when Karajan runs inside an AI agent, you lose visibility. The agent shows you the final result, but not the pipeline stages, iterations, or Solomon decisions happening in real time.
96
96
 
@@ -177,7 +177,7 @@ hu-reviewer? → triage → discover? → architect? → planner? → coder →
177
177
 
178
178
  Mix and match. Use Claude as coder and Codex as reviewer. Karajan auto-detects installed agents during `kj init`.
179
179
 
180
- ## MCP server (21 tools)
180
+ ## MCP server (23 tools)
181
181
 
182
182
  After `npm install -g karajan-code`, the MCP server auto-registers in Claude and Codex. Manual config if needed:
183
183
 
@@ -189,7 +189,7 @@ After `npm install -g karajan-code`, the MCP server auto-registers in Claude and
189
189
  # command = "karajan-mcp"
190
190
  ```
191
191
 
192
- **21 tools** available: `kj_run`, `kj_code`, `kj_review`, `kj_plan`, `kj_audit`, `kj_scan`, `kj_doctor`, `kj_config`, `kj_report`, `kj_resume`, `kj_roles`, `kj_agents`, `kj_preflight`, `kj_status`, `kj_init`, `kj_discover`, `kj_triage`, `kj_researcher`, `kj_architect`, `kj_impeccable`, `kj_hu`.
192
+ **23 tools** available: `kj_run`, `kj_code`, `kj_review`, `kj_plan`, `kj_audit`, `kj_scan`, `kj_doctor`, `kj_config`, `kj_report`, `kj_resume`, `kj_roles`, `kj_agents`, `kj_preflight`, `kj_status`, `kj_init`, `kj_discover`, `kj_triage`, `kj_researcher`, `kj_architect`, `kj_impeccable`, `kj_hu`, `kj_skills`, `kj_suggest`.
193
193
 
194
194
  Use `kj-tail` in a separate terminal to see what the pipeline is doing in real time (see [Three ways to use Karajan](#three-ways-to-use-karajan)).
195
195
 
@@ -243,7 +243,7 @@ Not nostalgia, not stubbornness. I've been using JavaScript since 1997, when Bre
243
243
  git clone https://github.com/manufosela/karajan-code.git
244
244
  cd karajan-code
245
245
  npm install
246
- npm test # Run 2142 tests with Vitest
246
+ npm test # Run 2318 tests with Vitest
247
247
  npm run validate # Lint + test
248
248
  ```
249
249
 
package/docs/README.es.md CHANGED
@@ -82,7 +82,7 @@ Este es el caso de uso principal. Karajan corre como servidor MCP dentro de Clau
82
82
  Tu → Claude Code → kj_run (via MCP) → triage → coder → sonar → reviewer → tester → security
83
83
  ```
84
84
 
85
- El servidor MCP se auto-registra durante `npm install`. Tu agente de IA ve 21 herramientas (`kj_run`, `kj_code`, `kj_review`, `kj_hu`, etc.) y las usa segun necesite.
85
+ El servidor MCP se auto-registra durante `npm install`. Tu agente de IA ve 23 herramientas (`kj_run`, `kj_code`, `kj_review`, `kj_hu`, etc.) y las usa segun necesite.
86
86
 
87
87
  **El problema**: cuando Karajan corre dentro de un agente de IA, pierdes visibilidad. El agente te muestra el resultado final, pero no las etapas del pipeline, iteraciones o decisiones de Solomon en tiempo real.
88
88
 
@@ -147,7 +147,7 @@ hu-reviewer? → triage → discover? → architect? → planner? → coder →
147
147
 
148
148
  Mezcla y combina. Usa Claude como coder y Codex como reviewer. Karajan auto-detecta agentes instalados durante `kj init`.
149
149
 
150
- ## Servidor MCP (21 herramientas)
150
+ ## Servidor MCP (23 herramientas)
151
151
 
152
152
  Tras `npm install -g karajan-code`, el servidor MCP se auto-registra en Claude y Codex. Config manual si es necesario:
153
153
 
@@ -159,7 +159,7 @@ Tras `npm install -g karajan-code`, el servidor MCP se auto-registra en Claude y
159
159
  # command = "karajan-mcp"
160
160
  ```
161
161
 
162
- **21 herramientas** disponibles: `kj_run`, `kj_code`, `kj_review`, `kj_plan`, `kj_audit`, `kj_scan`, `kj_doctor`, `kj_config`, `kj_report`, `kj_resume`, `kj_roles`, `kj_agents`, `kj_preflight`, `kj_status`, `kj_init`, `kj_discover`, `kj_triage`, `kj_researcher`, `kj_architect`, `kj_impeccable`, `kj_hu`.
162
+ **23 herramientas** disponibles: `kj_run`, `kj_code`, `kj_review`, `kj_plan`, `kj_audit`, `kj_scan`, `kj_doctor`, `kj_config`, `kj_report`, `kj_resume`, `kj_roles`, `kj_agents`, `kj_preflight`, `kj_status`, `kj_init`, `kj_discover`, `kj_triage`, `kj_researcher`, `kj_architect`, `kj_impeccable`, `kj_hu`, `kj_skills`, `kj_suggest`.
163
163
 
164
164
  Usa `kj-tail` en un terminal separado para ver lo que el pipeline esta haciendo en tiempo real (ver [Tres formas de usar Karajan](#tres-formas-de-usar-karajan)).
165
165
 
@@ -213,7 +213,7 @@ No es nostalgia ni cabezoneria. Es que llevo usando JavaScript desde 1997, cuand
213
213
  git clone https://github.com/manufosela/karajan-code.git
214
214
  cd karajan-code
215
215
  npm install
216
- npm test # Ejecutar 2142 tests con Vitest
216
+ npm test # Ejecutar 2318 tests con Vitest
217
217
  npm run validate # Lint + test
218
218
  ```
219
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.45.0",
3
+ "version": "1.46.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,122 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * build-sea.mjs — Build a Node.js Single Executable Application (SEA) binary.
5
+ *
6
+ * Steps:
7
+ * 1. esbuild bundles src/cli.js -> dist/kj-bundle.cjs (CJS, all deps inlined)
8
+ * 2. Generate SEA preparation blob via node --experimental-sea-config
9
+ * 3. Copy the current node binary to dist/kj (or dist/kj.exe on Windows)
10
+ * 4. Inject the blob into the binary with postject
11
+ * 5. (macOS only) Re-sign the binary with ad-hoc signature
12
+ *
13
+ * Requirements (installed globally or via npx):
14
+ * - esbuild
15
+ * - postject
16
+ *
17
+ * Usage:
18
+ * node scripts/build-sea.mjs
19
+ */
20
+
21
+ import { execSync } from "node:child_process";
22
+ import { cpSync, mkdirSync, writeFileSync, statSync } from "node:fs";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = path.dirname(__filename);
28
+ const ROOT = path.resolve(__dirname, "..");
29
+ const DIST = path.join(ROOT, "dist");
30
+
31
+ const isWindows = process.platform === "win32";
32
+ const isMac = process.platform === "darwin";
33
+ const binaryName = isWindows ? "kj.exe" : "kj";
34
+ const binaryPath = path.join(DIST, binaryName);
35
+
36
+ function run(cmd, opts = {}) {
37
+ console.log(` $ ${cmd}`);
38
+ execSync(cmd, { cwd: ROOT, stdio: "inherit", ...opts });
39
+ }
40
+
41
+ function formatBytes(bytes) {
42
+ const mb = bytes / (1024 * 1024);
43
+ return `${mb.toFixed(1)} MB`;
44
+ }
45
+
46
+ async function main() {
47
+ try {
48
+ console.log("\n=== Karajan Code — SEA Build ===\n");
49
+
50
+ // ── Step 0: Prepare dist directory ──────────────────────────────
51
+ mkdirSync(DIST, { recursive: true });
52
+
53
+ // ── Step 1: Bundle with esbuild ─────────────────────────────────
54
+ console.log("[1/5] Bundling with esbuild...");
55
+ const { seaBuildOptions } = await import("./esbuild-sea.config.mjs");
56
+ const esbuild = await import("esbuild");
57
+ await esbuild.build(seaBuildOptions);
58
+ console.log(" -> dist/kj-bundle.cjs created.\n");
59
+
60
+ // ── Step 2: Generate SEA blob ───────────────────────────────────
61
+ console.log("[2/5] Generating SEA preparation blob...");
62
+ const seaConfig = {
63
+ main: "dist/kj-bundle.cjs",
64
+ output: "dist/sea-prep.blob",
65
+ disableExperimentalSEAWarning: true,
66
+ };
67
+ writeFileSync(
68
+ path.join(DIST, "sea-config.json"),
69
+ JSON.stringify(seaConfig, null, 2),
70
+ );
71
+ run("node --experimental-sea-config dist/sea-config.json");
72
+ console.log(" -> dist/sea-prep.blob created.\n");
73
+
74
+ // ── Step 3: Copy node binary ────────────────────────────────────
75
+ console.log("[3/5] Copying node binary...");
76
+ cpSync(process.execPath, binaryPath);
77
+ console.log(` -> ${binaryName} copied.\n`);
78
+
79
+ // ── Step 3.5: Remove signature on macOS (required before postject)
80
+ if (isMac) {
81
+ console.log("[3.5] Removing macOS code signature...");
82
+ run(`codesign --remove-signature ${binaryPath}`);
83
+ console.log(" -> Signature removed.\n");
84
+ }
85
+
86
+ // ── Step 4: Inject blob with postject ───────────────────────────
87
+ console.log("[4/5] Injecting SEA blob with postject...");
88
+ const postjectCmd = [
89
+ `npx postject ${binaryPath}`,
90
+ "NODE_SEA_BLOB dist/sea-prep.blob",
91
+ "--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2",
92
+ ].join(" ");
93
+ run(postjectCmd);
94
+ console.log(" -> Blob injected.\n");
95
+
96
+ // ── Step 4.5: Re-sign on macOS ──────────────────────────────────
97
+ if (isMac) {
98
+ console.log("[4.5] Re-signing binary (ad-hoc)...");
99
+ run(`codesign --sign - ${binaryPath}`);
100
+ console.log(" -> Binary signed.\n");
101
+ }
102
+
103
+ // ── Done ────────────────────────────────────────────────────────
104
+ const stat = statSync(binaryPath);
105
+ console.log("[5/5] Done!");
106
+ console.log(` Binary: ${binaryPath}`);
107
+ console.log(` Size: ${formatBytes(stat.size)}`);
108
+ console.log("");
109
+ } catch (err) {
110
+ console.error("\n=== SEA Build FAILED ===\n");
111
+ console.error(err.message || err);
112
+ console.error(
113
+ "\nNote: esbuild may fail to bundle certain ESM-only dependencies",
114
+ );
115
+ console.error(
116
+ "or native modules. Check the error above for details.\n",
117
+ );
118
+ process.exit(1);
119
+ }
120
+ }
121
+
122
+ main();
@@ -0,0 +1,33 @@
1
+ /**
2
+ * esbuild configuration for Node.js SEA (Single Executable Application) bundle.
3
+ *
4
+ * Bundles the entire CLI into a single CJS file suitable for SEA injection.
5
+ * SEA requires CJS format — ESM entry points are transpiled by esbuild.
6
+ */
7
+
8
+ /** @type {import('esbuild').BuildOptions} */
9
+ export const seaBuildOptions = {
10
+ entryPoints: ["src/cli.js"],
11
+ outfile: "dist/kj-bundle.cjs",
12
+ format: "cjs",
13
+ platform: "node",
14
+ target: "node20",
15
+ bundle: true,
16
+ minify: false,
17
+ // Bundle everything — no externals. The binary must be fully self-contained.
18
+ external: [],
19
+ // Shim for CJS compatibility in the SEA context.
20
+ banner: {
21
+ js: [
22
+ "/* Karajan Code — SEA bundle */",
23
+ "/* eslint-disable */",
24
+ 'const __importMetaUrl = require("node:url").pathToFileURL(__filename).href;',
25
+ ].join("\n"),
26
+ },
27
+ // Replace import.meta.url references with the CJS-compatible shim variable.
28
+ define: {
29
+ "import.meta.url": "__importMetaUrl",
30
+ },
31
+ // Log level for debugging build issues.
32
+ logLevel: "info",
33
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Parallel HU execution using git worktrees.
3
+ *
4
+ * Analyses the dependency graph to find groups of HUs that can run
5
+ * concurrently, creates isolated git worktrees for each parallel HU,
6
+ * and merges results back sequentially.
7
+ */
8
+ import { execFile } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+ import path from "node:path";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ /** Directory inside the project where worktrees are created. */
15
+ const WORKTREE_DIR = ".kj/worktrees";
16
+
17
+ /**
18
+ * Given stories and their topological order, return groups (batches) of HUs
19
+ * that can execute in parallel. Two HUs can run in parallel when neither
20
+ * depends (directly or transitively) on the other.
21
+ *
22
+ * The algorithm walks the topological order and assigns each HU to the
23
+ * earliest batch where all its dependencies have already been placed in
24
+ * a previous batch.
25
+ *
26
+ * @param {Array<{id: string, blocked_by?: string[]}>} stories
27
+ * @param {string[]} order - topologically sorted story IDs
28
+ * @returns {string[][]} Array of batches; each batch is an array of story IDs.
29
+ */
30
+ export function findParallelGroups(stories, order) {
31
+ if (!order.length) return [];
32
+
33
+ const storyMap = new Map(stories.map(s => [s.id, s]));
34
+ // batchIndex[id] = index of the batch the story was assigned to
35
+ const batchIndex = new Map();
36
+ const batches = [];
37
+
38
+ for (const id of order) {
39
+ const story = storyMap.get(id);
40
+ const deps = story?.blocked_by || [];
41
+ // This HU must go into the batch *after* the latest dependency batch
42
+ let earliest = 0;
43
+ for (const dep of deps) {
44
+ if (batchIndex.has(dep)) {
45
+ earliest = Math.max(earliest, batchIndex.get(dep) + 1);
46
+ }
47
+ }
48
+ if (!batches[earliest]) batches[earliest] = [];
49
+ batches[earliest].push(id);
50
+ batchIndex.set(id, earliest);
51
+ }
52
+
53
+ return batches;
54
+ }
55
+
56
+ /**
57
+ * Create a git worktree for a given HU.
58
+ *
59
+ * @param {string} projectDir - Root directory of the git repo.
60
+ * @param {string} huId - Story identifier (used for branch and directory name).
61
+ * @returns {Promise<string>} Absolute path to the created worktree.
62
+ */
63
+ export async function createWorktree(projectDir, huId) {
64
+ const worktreePath = path.join(projectDir, WORKTREE_DIR, huId);
65
+ const branchName = `kj-hu-${huId}`;
66
+ await execFileAsync("git", ["worktree", "add", worktreePath, "-b", branchName], { cwd: projectDir });
67
+ return worktreePath;
68
+ }
69
+
70
+ /**
71
+ * Remove a git worktree (forcefully).
72
+ *
73
+ * @param {string} projectDir - Root directory of the git repo.
74
+ * @param {string} huId - Story identifier.
75
+ * @returns {Promise<void>}
76
+ */
77
+ export async function removeWorktree(projectDir, huId) {
78
+ const worktreePath = path.join(projectDir, WORKTREE_DIR, huId);
79
+ await execFileAsync("git", ["worktree", "remove", worktreePath, "--force"], { cwd: projectDir });
80
+ }
81
+
82
+ /**
83
+ * Merge a worktree branch back into the current branch and clean up.
84
+ *
85
+ * @param {string} projectDir - Root directory of the git repo.
86
+ * @param {string} huId - Story identifier.
87
+ * @returns {Promise<void>}
88
+ */
89
+ export async function mergeWorktree(projectDir, huId) {
90
+ const branchName = `kj-hu-${huId}`;
91
+ await execFileAsync("git", ["merge", branchName, "--no-edit"], { cwd: projectDir });
92
+ await removeWorktree(projectDir, huId);
93
+ await execFileAsync("git", ["branch", "-d", branchName], { cwd: projectDir });
94
+ }
@@ -6,6 +6,7 @@ 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
8
  import { refineHuWithContext } from "../hu/lazy-planner.js";
9
+ import { findParallelGroups, createWorktree, mergeWorktree, removeWorktree } from "../hu/parallel-executor.js";
9
10
 
10
11
  /**
11
12
  * Determine whether the HU reviewer result requires a sub-pipeline
@@ -60,6 +61,92 @@ function buildHuTask(story) {
60
61
  return `Implement HU ${story.id}`;
61
62
  }
62
63
 
64
+ /**
65
+ * Execute a single HU through the coder→sonar→reviewer sub-pipeline.
66
+ * Extracted to support both sequential and parallel execution paths.
67
+ *
68
+ * @param {object} params
69
+ * @returns {Promise<{huId: string, approved: boolean, result?: object, error?: string, blockedDependents?: string[]}>}
70
+ */
71
+ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath }) {
72
+ const story = batch.stories.find(s => s.id === storyId);
73
+
74
+ // --- Lazy refinement ---
75
+ if (story.needsRefinement && config) {
76
+ const completedHus = results
77
+ .filter(r => r.approved)
78
+ .map(r => {
79
+ const completedStory = batch.stories.find(s => s.id === r.huId);
80
+ return {
81
+ ...completedStory,
82
+ resultSummary: r.result?.summary || r.result?.reason || null
83
+ };
84
+ });
85
+
86
+ emitProgress(emitter, makeEvent("hu:refine-start", { ...eventBase, stage: "hu-sub-pipeline" }, {
87
+ message: `Refining HU ${story.id} with context from ${completedHus.length} completed HU(s)`,
88
+ detail: { huId: story.id, completedCount: completedHus.length }
89
+ }));
90
+
91
+ try {
92
+ const refined = await refineHuWithContext(story, completedHus, config, logger);
93
+ Object.assign(story, refined);
94
+ story.needsRefinement = false;
95
+ await saveHuBatch(batchSessionId, batch);
96
+
97
+ emitProgress(emitter, makeEvent("hu:refine-end", { ...eventBase, stage: "hu-sub-pipeline" }, {
98
+ message: `HU ${story.id} refined successfully`,
99
+ detail: { huId: story.id }
100
+ }));
101
+ } catch (err) {
102
+ logger.warn(`Lazy refinement failed for HU ${story.id}: ${err.message} — proceeding with original`);
103
+ story.needsRefinement = false;
104
+ }
105
+ }
106
+
107
+ const huTask = buildHuTask(story);
108
+
109
+ // --- hu:start ---
110
+ emitProgress(emitter, makeEvent("hu:start", { ...eventBase, stage: "hu-sub-pipeline" }, {
111
+ message: `Starting HU ${story.id}`,
112
+ detail: { huId: story.id, title: story.certified?.title || story.id, worktreePath: worktreePath || null }
113
+ }));
114
+
115
+ updateStoryStatus(batch, story.id, HU_STATUS.CODING);
116
+ await saveHuBatch(batchSessionId, batch);
117
+
118
+ try {
119
+ const iterResult = await runIterationFn(huTask);
120
+ const approved = Boolean(iterResult?.approved);
121
+
122
+ if (approved) {
123
+ updateStoryStatus(batch, story.id, HU_STATUS.DONE);
124
+ emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
125
+ status: "ok",
126
+ message: `HU ${story.id} completed successfully`,
127
+ detail: { huId: story.id, approved: true }
128
+ }));
129
+ return { huId: story.id, approved: true, result: iterResult };
130
+ } else {
131
+ updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
132
+ emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
133
+ status: "fail",
134
+ message: `HU ${story.id} failed`,
135
+ detail: { huId: story.id, approved: false, reason: iterResult?.reason }
136
+ }));
137
+ return { huId: story.id, approved: false, result: iterResult };
138
+ }
139
+ } catch (err) {
140
+ updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
141
+ emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
142
+ status: "fail",
143
+ message: `HU ${story.id} threw: ${err.message}`,
144
+ detail: { huId: story.id, approved: false, error: err.message }
145
+ }));
146
+ return { huId: story.id, approved: false, error: err.message };
147
+ }
148
+ }
149
+
63
150
  /**
64
151
  * Run all certified HUs as sub-pipelines in topological order.
65
152
  *
@@ -99,102 +186,85 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
99
186
  const blockedIds = [];
100
187
  let allApproved = true;
101
188
 
102
- for (const storyId of orderedIds) {
103
- const story = batch.stories.find(s => s.id === storyId);
104
- if (!story) continue;
189
+ // --- Group HUs into parallel batches ---
190
+ const parallelBatches = findParallelGroups(certifiedStories, orderedIds);
105
191
 
106
- // Skip if already blocked by a failed dependency
107
- if (story.status === HU_STATUS.BLOCKED) {
108
- blockedIds.push(story.id);
109
- continue;
110
- }
192
+ for (const group of parallelBatches) {
193
+ // Filter out HUs that were blocked by a failed dependency in a previous batch
194
+ const runnableIds = group.filter(id => {
195
+ const story = batch.stories.find(s => s.id === id);
196
+ return story && story.status !== HU_STATUS.BLOCKED;
197
+ });
111
198
 
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
- });
199
+ if (runnableIds.length === 0) continue;
123
200
 
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
- }));
201
+ // Emit parallel batch start event
202
+ emitProgress(emitter, makeEvent("hu:parallel-start", { ...eventBase, stage: "hu-sub-pipeline" }, {
203
+ message: `Starting parallel batch of ${runnableIds.length} HU(s): ${runnableIds.join(", ")}`,
204
+ detail: { batchIds: runnableIds, parallel: runnableIds.length > 1 }
205
+ }));
128
206
 
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;
207
+ if (runnableIds.length === 1) {
208
+ // Single HU: run as before, no worktree needed
209
+ const singleResult = await runSingleHu({
210
+ storyId: runnableIds[0], batch, batchSessionId, runIterationFn,
211
+ emitter, eventBase, logger, config, results
212
+ });
213
+ results.push(singleResult);
214
+ if (!singleResult.approved) {
215
+ allApproved = false;
216
+ const newlyBlocked = blockDependents(batch, singleResult.huId);
217
+ blockedIds.push(...newlyBlocked);
143
218
  }
144
- }
145
-
146
- const huTask = buildHuTask(story);
219
+ await saveHuBatch(batchSessionId, batch);
220
+ } else {
221
+ // Multiple HUs: create worktrees, run in parallel
222
+ const projectDir = config?.projectDir || process.cwd();
223
+ const worktrees = new Map();
147
224
 
148
- // --- hu:start ---
149
- emitProgress(emitter, makeEvent("hu:start", { ...eventBase, stage: "hu-sub-pipeline" }, {
150
- message: `Starting HU ${story.id}`,
151
- detail: { huId: story.id, title: story.certified?.title || story.id }
152
- }));
225
+ // Create worktrees for each HU in the batch
226
+ for (const id of runnableIds) {
227
+ try {
228
+ const wtPath = await createWorktree(projectDir, id);
229
+ worktrees.set(id, wtPath);
230
+ } catch (err) {
231
+ logger.warn(`Failed to create worktree for HU ${id}: ${err.message} — will run sequentially`);
232
+ }
233
+ }
153
234
 
154
- // Update status to coding
155
- updateStoryStatus(batch, story.id, HU_STATUS.CODING);
156
- await saveHuBatch(batchSessionId, batch);
235
+ // Run all HUs in the batch concurrently
236
+ const batchPromises = runnableIds.map(async (storyId) => {
237
+ return runSingleHu({
238
+ storyId, batch, batchSessionId, runIterationFn,
239
+ emitter, eventBase, logger, config, results,
240
+ worktreePath: worktrees.get(storyId)
241
+ });
242
+ });
157
243
 
158
- try {
159
- const iterResult = await runIterationFn(huTask);
160
- const approved = Boolean(iterResult?.approved);
161
-
162
- if (approved) {
163
- updateStoryStatus(batch, story.id, HU_STATUS.DONE);
164
- emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
165
- status: "ok",
166
- message: `HU ${story.id} completed successfully`,
167
- detail: { huId: story.id, approved: true }
168
- }));
169
- results.push({ huId: story.id, approved: true, result: iterResult });
170
- } else {
171
- updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
172
- const newlyBlocked = blockDependents(batch, story.id);
173
- blockedIds.push(...newlyBlocked);
174
- allApproved = false;
244
+ const batchResults = await Promise.all(batchPromises);
175
245
 
176
- emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
177
- status: "fail",
178
- message: `HU ${story.id} failed — ${newlyBlocked.length} dependent(s) blocked`,
179
- detail: { huId: story.id, approved: false, blockedDependents: newlyBlocked, reason: iterResult?.reason }
180
- }));
181
- results.push({ huId: story.id, approved: false, result: iterResult, blockedDependents: newlyBlocked });
246
+ // Process results: merge successful worktrees sequentially, clean up failed ones
247
+ for (const res of batchResults) {
248
+ results.push(res);
249
+ if (res.approved && worktrees.has(res.huId)) {
250
+ try {
251
+ await mergeWorktree(projectDir, res.huId);
252
+ } catch (err) {
253
+ logger.warn(`Failed to merge worktree for HU ${res.huId}: ${err.message}`);
254
+ }
255
+ } else if (!res.approved) {
256
+ allApproved = false;
257
+ const newlyBlocked = blockDependents(batch, res.huId);
258
+ blockedIds.push(...newlyBlocked);
259
+ // Clean up failed worktree
260
+ if (worktrees.has(res.huId)) {
261
+ try { await removeWorktree(projectDir, res.huId); } catch { /* ignore */ }
262
+ }
263
+ }
182
264
  }
183
- } catch (err) {
184
- updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
185
- const newlyBlocked = blockDependents(batch, story.id);
186
- blockedIds.push(...newlyBlocked);
187
- allApproved = false;
188
265
 
189
- emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
190
- status: "fail",
191
- message: `HU ${story.id} threw: ${err.message}`,
192
- detail: { huId: story.id, approved: false, error: err.message, blockedDependents: newlyBlocked }
193
- }));
194
- results.push({ huId: story.id, approved: false, error: err.message, blockedDependents: newlyBlocked });
266
+ await saveHuBatch(batchSessionId, batch);
195
267
  }
196
-
197
- await saveHuBatch(batchSessionId, batch);
198
268
  }
199
269
 
200
270
  return { approved: allApproved, results, blockedIds };