karajan-code 3.14.0 → 3.15.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.
Files changed (35) hide show
  1. package/README.md +10 -0
  2. package/package.json +1 -1
  3. package/packages/hu-board/src/routes/api.js +7 -3
  4. package/scripts/verify-pack.mjs +5 -1
  5. package/src/agents/claude-agent.js +9 -4
  6. package/src/agents/model-registry.js +23 -0
  7. package/src/config/defaults.js +4 -0
  8. package/src/git/hu-automation.js +6 -5
  9. package/src/hu/acceptance-runner.js +8 -4
  10. package/src/hu/worktree-bootstrap.js +59 -0
  11. package/src/orchestrator/drivers/iteration-phases/guards.js +1 -1
  12. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +1 -1
  13. package/src/orchestrator/drivers/run-hu-batch.js +82 -52
  14. package/src/orchestrator/hu-sub-pipeline.js +26 -2
  15. package/src/orchestrator/stages/coder-stage.js +10 -2
  16. package/src/orchestrator/stages/reviewer-stage.js +3 -3
  17. package/src/plan/plan-executor.js +4 -0
  18. package/src/plan/preflight-hu.js +3 -0
  19. package/src/rag/embedder.js +2 -81
  20. package/src/rag/embedders/_cloud-base.js +2 -24
  21. package/src/rag/embedders/cohere.js +2 -28
  22. package/src/rag/embedders/factory.js +2 -29
  23. package/src/rag/embedders/mistral.js +2 -26
  24. package/src/rag/embedders/onnx.js +2 -63
  25. package/src/rag/embedders/openai.js +2 -22
  26. package/src/rag/embedders/voyage.js +2 -18
  27. package/src/rag/rerank.js +4 -74
  28. package/src/rag/retriever.js +2 -127
  29. package/src/rag/where-parser.js +4 -54
  30. package/src/review/diff-generator.js +2 -2
  31. package/src/roles/agent-role.js +4 -0
  32. package/src/roles/coder-role.js +8 -2
  33. package/src/spec-review/run-spec-review.js +5 -1
  34. package/src/utils/cli-ask-question.js +9 -0
  35. package/src/utils/git.js +12 -11
package/README.md CHANGED
@@ -383,6 +383,16 @@ Each AI role is executed by the agent you choose:
383
383
  >
384
384
  > Full per-stage reference: [Pipeline roles](https://karajan-code.web.app/docs/handbook/pipeline-roles/) (handbook).
385
385
 
386
+ ## Step mode and parallel lanes (v3.14+)
387
+
388
+ Two ways to control how a plan executes:
389
+
390
+ **`kj run --step`** — supervise the orchestra iteration by iteration. After every iteration the pipeline pauses with a compact report (what happened, the reviewer's must-fix list, what the next iteration will do, spend vs cap) and asks: press Enter to continue, type `stop` to halt (resumable with `kj resume`), or **type instructions** — free text is injected into the feedback the coder reads next iteration, without clobbering the reviewer's own findings. Also offered as a question in the `kj init` wizard (`session.iteration_gate`).
391
+
392
+ **`kj run --plan <id> --parallel <n>`** — run a plan's independent HUs concurrently, each in its own **git worktree** under `.kj/worktrees/<huId>` on branch `kj-hu-<huId>`. The scheduler walks the `blocked_by` graph and only pairs HUs with disjoint `scope` paths (scopeless HUs run alone); the whole lane — coder, acceptance tests, diffs, sonar, final commit — runs inside its worktree while the main working tree stays parked. Governance is built in: default is `1` (fully sequential), a plan-level budget ceiling (`n × max_budget_usd`) stops the batch loudly when exhausted, and SonarQube serializes across lanes. Each fresh worktree is bootstrapped automatically (submodules + `npm ci`, or your `session.worktree_setup` command) and receives `KJ_LANE_SLOT` / `KJ_PORT_OFFSET` env vars so services started by tests don't collide on ports.
393
+
394
+ Full guide: [`docs/parallel-hus.md`](docs/parallel-hus.md).
395
+
386
396
  ## 5 AI agents supported
387
397
 
388
398
  | Agent | CLI | Install |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.14.0",
3
+ "version": "3.15.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",
@@ -76,7 +76,9 @@ router.get('/version', (_req, res) => {
76
76
  */
77
77
  router.get('/standby', async (_req, res) => {
78
78
  try {
79
- const { listPendingStandby } = await import('../../../../src/brain/standby-store.js');
79
+ // KJC-TSK-0632: resolve from karajan-core directly — the CLI's
80
+ // src/brain/standby-store.js is just a re-export shim of this.
81
+ const { listPendingStandby } = await import('karajan-core/standby-store');
80
82
  const sessions = listPendingStandby();
81
83
  res.set('Cache-Control', 'no-store');
82
84
  res.json({ sessions });
@@ -1362,8 +1364,10 @@ router.post('/rag/query', async (req, res) => {
1362
1364
  }
1363
1365
  try {
1364
1366
  const { openVecStore, countChunks } = await import('karajan-core/vec-store');
1365
- const { makeEmbedder } = await import('../../../../src/rag/embedders/factory.js');
1366
- const { query } = await import('../../../../src/rag/retriever.js');
1367
+ // KJC-TSK-0632: resolved from karajan-core — the board carries zero
1368
+ // relative imports into the CLI src tree (see no-cli-imports test).
1369
+ const { makeEmbedder } = await import('karajan-core/rag/embedders/factory');
1370
+ const { query } = await import('karajan-core/rag/retriever');
1367
1371
  const db = openVecStore({ dim: 768 });
1368
1372
  try {
1369
1373
  if (countChunks(db) === 0) return res.json({ hits: [], empty: true, topK, scope });
@@ -166,7 +166,11 @@ try {
166
166
  console.log(`verify-pack: installing the tarball with pnpm into ${pnpmTmp}…`);
167
167
  // pnpm exits non-zero on ERR_PNPM_IGNORED_BUILDS (it skips native build
168
168
  // scripts by default) — expected here, so don't treat the exit as failure.
169
- spawnSync("pnpm", ["add", tgzPath, "--store-dir", path.join(pnpmTmp, ".store")], {
169
+ // minimum-release-age=0: modern pnpm quarantines freshly published
170
+ // versions (supply-chain protection), so right after publishing
171
+ // karajan-core it silently resolves an OLD one and this smoke fails on
172
+ // missing subpaths. The gate verifies packaging, not release-age policy.
173
+ spawnSync("pnpm", ["add", tgzPath, "--store-dir", path.join(pnpmTmp, ".store"), "--config.minimum-release-age=0"], {
170
174
  encoding: "utf8",
171
175
  env: childEnv,
172
176
  cwd: pnpmTmp,
@@ -300,7 +300,10 @@ export function createStreamJsonFilter(onOutput) {
300
300
  */
301
301
  function cleanExecaOpts(extra = {}) {
302
302
  const { CLAUDECODE: _CLAUDECODE, ...env } = process.env;
303
- return { env, stdin: "ignore", ...extra };
303
+ // PAR-H (KJC-TSK-0631): extra.env ADDS to the inherited env (lane slot
304
+ // vars) instead of replacing it wholesale.
305
+ const { env: extraEnv, ...rest } = extra;
306
+ return { env: extraEnv ? { ...env, ...extraEnv } : env, stdin: "ignore", ...rest };
304
307
  }
305
308
 
306
309
  /**
@@ -370,7 +373,8 @@ export class ClaudeAgent extends BaseAgent {
370
373
  const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({
371
374
  onOutput: streamFilter,
372
375
  silenceTimeoutMs: task.silenceTimeoutMs,
373
- timeout: task.timeoutMs
376
+ timeout: task.timeoutMs,
377
+ env: task.env
374
378
  }));
375
379
  const raw = pickOutput(res);
376
380
  const output = extractTextFromStreamJson(raw);
@@ -380,7 +384,7 @@ export class ClaudeAgent extends BaseAgent {
380
384
 
381
385
  // Without streaming, use json output to get structured response via stderr
382
386
  args.push("--output-format", "json");
383
- const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts());
387
+ const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: task.env }));
384
388
  const raw = pickOutput(res);
385
389
  const output = extractTextFromStreamJson(raw);
386
390
  const usage = extractUsageFromStreamJson(raw);
@@ -393,7 +397,8 @@ export class ClaudeAgent extends BaseAgent {
393
397
  const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({
394
398
  onOutput: task.onOutput,
395
399
  silenceTimeoutMs: task.silenceTimeoutMs,
396
- timeout: task.timeoutMs
400
+ timeout: task.timeoutMs,
401
+ env: task.env
397
402
  }));
398
403
  const raw = pickOutput(res);
399
404
  const usage = extractUsageFromStreamJson(raw);
@@ -108,6 +108,29 @@ registerModelAlias("gemini", "gemini-2.5-pro");
108
108
  registerModel("aider", { provider: "aider", pricing: { input_per_million: 3, output_per_million: 15 } });
109
109
  registerModel("opencode", { provider: "opencode", pricing: { input_per_million: 0, output_per_million: 0 } });
110
110
 
111
+ /**
112
+ * Moonshot Kimi Family (KJC-TSK-0633) — consumed through OpenCode as an
113
+ * OpenAI-compatible provider (docs/providers-via-opencode.md).
114
+ * Pricing: https://platform.moonshot.ai/docs/pricing — verify before
115
+ * trusting for billing decisions; ids/prices move fast.
116
+ */
117
+ registerModel("kimi-k2", { provider: "moonshot", pricing: { input_per_million: 0.6, output_per_million: 2.5 } });
118
+ registerModel("kimi-k2-thinking", { provider: "moonshot", pricing: { input_per_million: 0.6, output_per_million: 2.5 } });
119
+ registerModelAlias("kimi", "kimi-k2");
120
+ // Prefixed ids as the documented opencode.json snippet produces them.
121
+ registerModelAlias("kimi/kimi-k2", "kimi-k2");
122
+ registerModelAlias("kimi/kimi-k2-thinking", "kimi-k2-thinking");
123
+
124
+ /**
125
+ * DeepSeek Family (KJC-TSK-0633) — same OpenCode route.
126
+ * Pricing: https://api-docs.deepseek.com/quick_start/pricing
127
+ */
128
+ registerModel("deepseek-chat", { provider: "deepseek", pricing: { input_per_million: 0.28, output_per_million: 0.42 } });
129
+ registerModel("deepseek-reasoner", { provider: "deepseek", pricing: { input_per_million: 0.28, output_per_million: 0.42 } });
130
+ registerModelAlias("deepseek", "deepseek-chat");
131
+ registerModelAlias("deepseek/deepseek-chat", "deepseek-chat");
132
+ registerModelAlias("deepseek/deepseek-reasoner", "deepseek-reasoner");
133
+
111
134
  // Common CLI Aliases (with provider overrides)
112
135
  registerModelAlias("aider/claude-3-7-sonnet", "claude-sonnet-4.6", { provider: "aider" });
113
136
  registerModelAlias("aider/gpt-4o", "gpt-5.4-standard", { provider: "aider" });
@@ -182,6 +182,10 @@ const DEFAULTS = {
182
182
  // Concurrent HU lanes per plan run (KJC-TSK-0626). 1 = sequential.
183
183
  // Raising it multiplies token burn rate — the plan budget scales with it.
184
184
  max_parallel_hus: 1,
185
+ // Command run inside each fresh lane worktree before the coder starts
186
+ // (KJC-TSK-0630). null = auto-detect: `npm ci` when package-lock.json
187
+ // exists, nothing otherwise. Submodules are always initialized first.
188
+ worktree_setup: null,
185
189
  max_iteration_minutes: 30,
186
190
  max_total_minutes: 120,
187
191
  max_planner_minutes: 60,
@@ -113,11 +113,11 @@ export async function prepareHuBranch({ story, huBranches, config, logger }) {
113
113
  * @param {object} params.logger
114
114
  * @returns {Promise<{committed: boolean, pushed: boolean, prUrl: string|null}>}
115
115
  */
116
- export async function finalizeHuCommit({ story, branchName, config, logger }) {
116
+ export async function finalizeHuCommit({ story, branchName, config, logger, cwd = null }) {
117
117
  const result = { committed: false, pushed: false, prUrl: null };
118
118
  if (!branchName) return result;
119
119
 
120
- const changed = await hasChanges();
120
+ const changed = await hasChanges(cwd);
121
121
  if (!changed) {
122
122
  logger.info(`HU ${story.id}: no changes to commit`);
123
123
  return result;
@@ -126,7 +126,7 @@ export async function finalizeHuCommit({ story, branchName, config, logger }) {
126
126
  const title = story.title || story.id;
127
127
  const commitMsg = `feat(${story.id}): ${title}`;
128
128
  if (config.git?.auto_commit) {
129
- const commitRes = await commitAll(commitMsg);
129
+ const commitRes = await commitAll(commitMsg, cwd);
130
130
  if (commitRes) {
131
131
  result.committed = true;
132
132
  logger.info(`HU ${story.id}: committed on '${branchName}'`);
@@ -135,7 +135,7 @@ export async function finalizeHuCommit({ story, branchName, config, logger }) {
135
135
 
136
136
  if (config.git?.auto_push && result.committed) {
137
137
  try {
138
- await pushBranch(branchName);
138
+ await pushBranch(branchName, cwd);
139
139
  result.pushed = true;
140
140
  logger.info(`HU ${story.id}: pushed '${branchName}'`);
141
141
  } catch (err) {
@@ -157,7 +157,8 @@ export async function finalizeHuCommit({ story, branchName, config, logger }) {
157
157
  baseBranch: config.base_branch || "main",
158
158
  branch: branchName,
159
159
  title: commitMsg,
160
- body: prBody
160
+ body: prBody,
161
+ cwd
161
162
  });
162
163
  result.prUrl = url;
163
164
  logger.info(`HU ${story.id}: PR created ${url}`);
@@ -12,11 +12,15 @@ import { runCommand } from "../utils/process.js";
12
12
  * @param {number} [timeoutMs=30000] - Timeout per test
13
13
  * @returns {Promise<{cmd: string, passed: boolean, output: string, exitCode: number}>}
14
14
  */
15
- async function runSingleTest(cmd, cwd, timeoutMs = 30000) {
15
+ async function runSingleTest(cmd, cwd, timeoutMs = 30000, env = null) {
16
16
  try {
17
17
  const result = await runCommand("bash", ["-c", cmd], {
18
18
  timeout: timeoutMs,
19
- cwd
19
+ cwd,
20
+ // PAR-H (KJC-TSK-0631): lane env (KJ_LANE_SLOT / KJ_PORT_OFFSET) so
21
+ // tests that start services can offset their ports. execa merges
22
+ // this on top of process.env.
23
+ ...(env ? { env } : {})
20
24
  });
21
25
  const output = (result.stdout || "") + (result.stderr || "");
22
26
  return {
@@ -51,7 +55,7 @@ async function runSingleTest(cmd, cwd, timeoutMs = 30000) {
51
55
  * @param {string} cwd - Working directory
52
56
  * @returns {Promise<{allPassed: boolean, results: object[], summary: string, diagnostics: string|null, pending: number}>}
53
57
  */
54
- export async function runAcceptanceTests(tests, cwd) {
58
+ export async function runAcceptanceTests(tests, cwd, { env = null } = {}) {
55
59
  if (!tests || tests.length === 0) {
56
60
  return { allPassed: false, results: [], summary: "No acceptance tests defined", diagnostics: null, pending: 0 };
57
61
  }
@@ -86,7 +90,7 @@ export async function runAcceptanceTests(tests, cwd) {
86
90
  });
87
91
  continue;
88
92
  }
89
- const result = await runSingleTest(cmd, cwd);
93
+ const result = await runSingleTest(cmd, cwd, 30000, env);
90
94
  results.push({ ...result, type: "shell" });
91
95
  }
92
96
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * PAR-G (KJC-TSK-0630): make a freshly created lane worktree operative.
3
+ *
4
+ * `git worktree add` produces a clean checkout: no node_modules, no
5
+ * initialized submodules — and a container/bind-mounted process cannot
6
+ * init them itself because the worktree's real .git lives in the parent
7
+ * repo (gotcha reported by Jorge del Casar's worktree-docker-envs skill).
8
+ * Without this step, --parallel lanes die on the first `npm test` in any
9
+ * real project.
10
+ *
11
+ * Best-effort by contract: a failed or slow bootstrap warns and the lane
12
+ * continues — the acceptance tests deliver the real verdict.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { runCommand } from "../utils/process.js";
17
+
18
+ const STEP_TIMEOUT_MS = 5 * 60 * 1000;
19
+
20
+ /**
21
+ * @param {object} params
22
+ * @param {string} params.worktreePath Absolute path of the lane worktree.
23
+ * @param {string|null} [params.setupCommand] session.worktree_setup — wins over auto-detect.
24
+ * @param {object|null} [params.logger]
25
+ * @param {number} [params.timeoutMs]
26
+ * @param {Function} [params.run] Injectable runner (tests).
27
+ * @returns {Promise<{ok: boolean, steps: string[], warnings: string[]}>}
28
+ */
29
+ export async function bootstrapWorktree({ worktreePath, setupCommand = null, logger = null, timeoutMs = STEP_TIMEOUT_MS, run = runCommand }) {
30
+ const warnings = [];
31
+ const steps = [];
32
+
33
+ if (existsSync(join(worktreePath, ".gitmodules"))) {
34
+ steps.push({ name: "submodules", cmd: "git", args: ["submodule", "update", "--init", "--recursive"] });
35
+ }
36
+
37
+ const explicit = typeof setupCommand === "string" && setupCommand.trim() ? setupCommand.trim() : null;
38
+ if (explicit) {
39
+ steps.push({ name: "setup", cmd: "sh", args: ["-c", explicit] });
40
+ } else if (existsSync(join(worktreePath, "package-lock.json"))) {
41
+ steps.push({ name: "deps", cmd: "npm", args: ["ci", "--no-audit", "--no-fund"] });
42
+ }
43
+
44
+ for (const step of steps) {
45
+ try {
46
+ const res = await run(step.cmd, step.args, { cwd: worktreePath, timeout: timeoutMs });
47
+ if (res.exitCode !== 0) {
48
+ warnings.push(`${step.name} failed (exit ${res.exitCode}): ${String(res.stderr || res.stdout || "").slice(0, 200)}`);
49
+ } else {
50
+ logger?.info?.(`worktree bootstrap: ${step.name} ok`);
51
+ }
52
+ } catch (err) {
53
+ warnings.push(`${step.name} threw: ${err.message}`);
54
+ }
55
+ }
56
+
57
+ for (const w of warnings) logger?.warn?.(`worktree bootstrap: ${w} — lane continues`);
58
+ return { ok: warnings.length === 0, steps: steps.map((s) => s.name), warnings };
59
+ }
@@ -28,7 +28,7 @@ export async function runGuardStages({ config, logger, emitter, eventBase, sessi
28
28
  let diff;
29
29
  try {
30
30
  const baseRef = await computeBaseRef({ baseBranch });
31
- diff = await generateDiff({ baseRef });
31
+ diff = await generateDiff({ baseRef, projectDir: config?.projectDir || null });
32
32
  } catch {
33
33
  logger.warn("Guards: could not generate diff, skipping");
34
34
  return { action: "ok" };
@@ -77,7 +77,7 @@ export async function runQualityGateStages({ config, logger, emitter, eventBase,
77
77
  }
78
78
 
79
79
  if (pipelineFlags?.impeccableEnabled) {
80
- const diff = await generateDiff({ baseRef: session.session_start_sha });
80
+ const diff = await generateDiff({ baseRef: session.session_start_sha, projectDir: config?.projectDir || null });
81
81
  const impeccableMode = pipelineFlags?.impeccableMode || "audit";
82
82
  const impeccableResult = await runImpeccableStage({
83
83
  config, logger, emitter, eventBase, session, coderRole, trackBudget,
@@ -55,7 +55,6 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
55
55
  }));
56
56
 
57
57
  // Per-HU pipeline: focused max_iterations, fresh Brain state, own git branch.
58
- const originalMaxIterations = ctx.config.max_iterations;
59
58
  const huMaxIterations = ctx.config.hu_max_iterations ?? 3;
60
59
  const huBranches = new Map();
61
60
  const { prepareHuBranch, finalizeHuCommit } = await import("../../git/hu-automation.js");
@@ -83,15 +82,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
83
82
 
84
83
  const subPipelineResult = await runHuSubPipeline({
85
84
  huReviewerResult: ctx.stageResults.huReviewer,
86
- runIterationFn: async (huTask, story) => {
87
- ctx.config.max_iterations = huMaxIterations;
88
- if (ctx.brainCtx?.enabled) {
89
- ctx.brainCtx.extensionCount = 0;
90
- const { createBrainContext } = await import("../brain-coordinator.js");
91
- const fresh = createBrainContext({ enabled: true });
92
- ctx.brainCtx.feedbackQueue = fresh.feedbackQueue;
93
- ctx.brainCtx.verificationTracker = fresh.verificationTracker;
94
- }
85
+ runIterationFn: async (huTask, story, laneOpts = {}) => {
95
86
  // Apply per-HU policies based on task_type (infra skips reviewer/sonar/tdd).
96
87
  // KJC-TSK-0400: `effectiveTaskType` mira primero story.task_type y, si
97
88
  // no es válido, infiere del prefijo del title ([SPIKE], [DOC]…). Así
@@ -103,15 +94,56 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
103
94
  logger.info(`HU ${story.id}: task_type inferido del title → ${resolvedTaskType} (era ${story.task_type || "null"})`);
104
95
  }
105
96
  const huPolicies = applyPolicies({ taskType: resolvedTaskType, policies: ctx.config.policies });
106
- const savedFlags = { ...ctx.pipelineFlags };
107
- if (!huPolicies.reviewer) ctx.pipelineFlags.reviewerEnabled = false;
108
- if (!huPolicies.tdd) ctx.config.development = { ...ctx.config.development, methodology: "standard", require_test_changes: false };
109
- if (!huPolicies.sonar) ctx.config.sonarqube = { ...ctx.config.sonarqube, enabled: false };
110
- if (!huPolicies.testsRequired) ctx.pipelineFlags.testerEnabled = false;
111
97
  logger.info(`HU ${story.id} (${resolvedTaskType}): policies → reviewer=${huPolicies.reviewer}, tdd=${huPolicies.tdd}, sonar=${huPolicies.sonar}, tests=${huPolicies.testsRequired}`);
112
98
 
113
- const branchName = await prepareHuBranch({ story, huBranches, config: ctx.config, logger });
114
- const projectDir = ctx.config.projectDir || process.cwd();
99
+ // PAR-E2 PR2 (KJC-TSK-0629): every per-HU adjustment lands on LANE
100
+ // copies. ctx.config/pipelineFlags/session/brainCtx are shared by all
101
+ // lanes, so concurrent lanes must never mutate them. This also fixes a
102
+ // latent leak: max_iterations was mutated on ctx.config and only the
103
+ // fallback path restored it — the acceptance path returned early and
104
+ // left it clamped to hu_max_iterations for the rest of the run.
105
+ const worktreePath = laneOpts?.worktreePath || null;
106
+ const projectDir = worktreePath || ctx.config.projectDir || process.cwd();
107
+ // PAR-H (KJC-TSK-0631): the lane's slot travels as env vars so any
108
+ // service the coder or the acceptance tests start can offset its
109
+ // ports (offset = slot × 100; the project applies its own base).
110
+ const laneEnv = Number.isInteger(laneOpts?.laneSlot)
111
+ ? { KJ_LANE_SLOT: String(laneOpts.laneSlot), KJ_PORT_OFFSET: String(laneOpts.laneSlot * 100) }
112
+ : null;
113
+ const laneConfig = { ...ctx.config, projectDir, max_iterations: huMaxIterations, lane_env: laneEnv };
114
+ if (!huPolicies.tdd) laneConfig.development = { ...laneConfig.development, methodology: "standard", require_test_changes: false };
115
+ if (!huPolicies.sonar) laneConfig.sonarqube = { ...laneConfig.sonarqube, enabled: false };
116
+ const laneFlags = { ...ctx.pipelineFlags };
117
+ if (!huPolicies.reviewer) laneFlags.reviewerEnabled = false;
118
+ if (!huPolicies.testsRequired) laneFlags.testerEnabled = false;
119
+ // Worktree lanes clone the session so reviewer feedback never leaks
120
+ // across concurrent HUs. Disk saves still share the session id (last
121
+ // writer wins) — journal noise at most; in-memory isolation is what
122
+ // guards correctness.
123
+ const laneSession = worktreePath ? { ...ctx.session } : ctx.session;
124
+ let laneBrain = ctx.brainCtx;
125
+ if (ctx.brainCtx?.enabled) {
126
+ const { createBrainContext } = await import("../brain-coordinator.js");
127
+ const fresh = createBrainContext({ enabled: true });
128
+ if (worktreePath) {
129
+ laneBrain = { ...ctx.brainCtx, extensionCount: 0, feedbackQueue: fresh.feedbackQueue, verificationTracker: fresh.verificationTracker };
130
+ } else {
131
+ ctx.brainCtx.extensionCount = 0;
132
+ ctx.brainCtx.feedbackQueue = fresh.feedbackQueue;
133
+ ctx.brainCtx.verificationTracker = fresh.verificationTracker;
134
+ }
135
+ }
136
+ let lanePlannedTask = ctx.plannedTask;
137
+
138
+ // A worktree lane skips the main-tree checkout: `git worktree add -b`
139
+ // already left the worktree checked out on kj-hu-<id>.
140
+ let branchName;
141
+ if (worktreePath) {
142
+ branchName = `kj-hu-${story.id}`;
143
+ huBranches.set(story.id, branchName);
144
+ } else {
145
+ branchName = await prepareHuBranch({ story, huBranches, config: laneConfig, logger });
146
+ }
115
147
 
116
148
  // KJC-TSK-0408 step 2: snapshot del workspace ANTES de invocar al
117
149
  // coder. Si el usuario hace Undo después, restaura este SHA. El
@@ -142,11 +174,11 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
142
174
  // iterations against an irreparable test (the 2026-04-29 bug).
143
175
  const failureTracker = createFailureTracker();
144
176
 
145
- for (let attempt = 1; attempt <= ctx.config.max_iterations; attempt++) {
146
- logger.info(`HU ${story.id}: coder iteration ${attempt}/${ctx.config.max_iterations}`);
177
+ for (let attempt = 1; attempt <= laneConfig.max_iterations; attempt++) {
178
+ logger.info(`HU ${story.id}: coder iteration ${attempt}/${laneConfig.max_iterations}`);
147
179
  emitProgress(emitter, makeEvent("iteration:start", { ...ctx.eventBase, stage: "iteration" }, {
148
- message: `Iteration ${attempt}/${ctx.config.max_iterations}`,
149
- detail: { iteration: attempt, maxIterations: ctx.config.max_iterations }
180
+ message: `Iteration ${attempt}/${laneConfig.max_iterations}`,
181
+ detail: { iteration: attempt, maxIterations: laneConfig.max_iterations }
150
182
  }));
151
183
 
152
184
  // Coder runs with the HU task + any diagnostic feedback from previous attempt.
@@ -155,9 +187,9 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
155
187
  // not only after the first failed run.
156
188
  const coderResult = await runCoderStage({
157
189
  coderRoleInstance: ctx.coderRoleInstance, coderRole: ctx.coderRole,
158
- config: ctx.config, logger, emitter, eventBase: ctx.eventBase,
159
- session: ctx.session, plannedTask: ctx.plannedTask,
160
- trackBudget: ctx.trackBudget, iteration: attempt, brainCtx: ctx.brainCtx,
190
+ config: laneConfig, logger, emitter, eventBase: ctx.eventBase,
191
+ session: laneSession, plannedTask: lanePlannedTask,
192
+ trackBudget: ctx.trackBudget, iteration: attempt, brainCtx: laneBrain,
161
193
  acceptanceTests: story.acceptance_tests,
162
194
  // PR F (v2.7.5): plan-aware coder context. ADRs are
163
195
  // shared across the plan, the rest are scoped to this
@@ -173,11 +205,11 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
173
205
  }
174
206
 
175
207
  // Sonar quality gate (sw task_type only, when policies say sonar=true)
176
- if (huPolicies.sonar && ctx.config.sonarqube?.enabled) {
208
+ if (huPolicies.sonar && laneConfig.sonarqube?.enabled) {
177
209
  try {
178
210
  const sonarResult = await runSonarStage({
179
- config: ctx.config, logger, emitter, eventBase: ctx.eventBase,
180
- session: ctx.session, trackBudget: ctx.trackBudget, iteration: attempt,
211
+ config: laneConfig, logger, emitter, eventBase: ctx.eventBase,
212
+ session: laneSession, trackBudget: ctx.trackBudget, iteration: attempt,
181
213
  // sonarState is required: runSonarStage reads/writes
182
214
  // .issuesInitial / .issuesFinal on it. Forgetting this
183
215
  // surfaces as `Cannot read properties of undefined
@@ -188,11 +220,11 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
188
220
  sonarState: ctx.sonarState,
189
221
  repeatDetector: ctx.repeatDetector,
190
222
  budgetSummary: ctx.budgetSummary,
191
- askQuestion, brainCtx: ctx.brainCtx
223
+ askQuestion, brainCtx: laneBrain
192
224
  });
193
225
  if (sonarResult?.action === "continue") {
194
226
  // Sonar failed — add to feedback for next coder attempt
195
- ctx.plannedTask = `${huTask}\n\n--- SONAR FAILURE ---\n${ctx.session.last_reviewer_feedback}`;
227
+ lanePlannedTask = `${huTask}\n\n--- SONAR FAILURE ---\n${laneSession.last_reviewer_feedback}`;
196
228
  continue;
197
229
  }
198
230
  } catch (err) {
@@ -207,7 +239,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
207
239
  detail: { huId: story.id, testCount: story.acceptance_tests.length }
208
240
  }));
209
241
 
210
- const testResult = await runAcceptanceTests(story.acceptance_tests, projectDir);
242
+ const testResult = await runAcceptanceTests(story.acceptance_tests, projectDir, { env: laneEnv });
211
243
  emitProgress(emitter, makeEvent("hu:acceptance-end", { ...ctx.eventBase, stage: "acceptance" }, {
212
244
  status: testResult.allPassed ? "ok" : "fail",
213
245
  message: testResult.summary,
@@ -225,8 +257,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
225
257
  );
226
258
  if (pendingGherkinTests.length === 0) {
227
259
  logger.info(`HU ${story.id}: all shell acceptance tests PASSED, no Gherkin to translate — approved`);
228
- await finalizeHuCommit({ story, branchName, config: ctx.config, logger });
229
- return { approved: true, sessionId: ctx.session.id, reason: "acceptance_tests_passed" };
260
+ await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
261
+ return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed" };
230
262
  }
231
263
 
232
264
  // Shell tests passed; Gherkin needs translation. Invoke the
@@ -236,16 +268,16 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
236
268
  const { runTesterStage } = await import("../post-loop-stages.js");
237
269
  const shellResults = testResult.results.filter((r) => r.type !== "gherkin");
238
270
  const testerOutcome = await runTesterStage({
239
- config: ctx.config, logger, emitter, eventBase: ctx.eventBase,
240
- session: ctx.session, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget,
271
+ config: laneConfig, logger, emitter, eventBase: ctx.eventBase,
272
+ session: laneSession, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget,
241
273
  iteration: attempt, task: huTask, diff: null, askQuestion,
242
274
  pendingGherkinTests, shellTestResults: shellResults,
243
275
  });
244
276
  const testerStage = testerOutcome?.stageResult;
245
277
  if (testerOutcome?.action !== "continue" && testerStage?.verdict === "pass") {
246
278
  logger.info(`HU ${story.id}: tester translated Gherkin and verdict=pass — approved`);
247
- await finalizeHuCommit({ story, branchName, config: ctx.config, logger });
248
- return { approved: true, sessionId: ctx.session.id, reason: "acceptance_tests_passed" };
279
+ await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
280
+ return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed" };
249
281
  }
250
282
 
251
283
  // Tester rejected (translated tests failed or coverage
@@ -259,8 +291,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
259
291
  "Fix the implementation so every scenario passes. Do NOT soften the scenarios.",
260
292
  ].filter(Boolean).join("\n");
261
293
  logger.warn(`HU ${story.id}: tester verdict=fail after Gherkin translation — sending back to coder`);
262
- setReviewerFeedback(ctx.session, diagnostic);
263
- ctx.plannedTask = `${huTask}\n\n--- GHERKIN TRANSLATION FAILURES ---\n${diagnostic}`;
294
+ setReviewerFeedback(laneSession, diagnostic);
295
+ lanePlannedTask = `${huTask}\n\n--- GHERKIN TRANSLATION FAILURES ---\n${diagnostic}`;
264
296
  continue;
265
297
  }
266
298
 
@@ -314,7 +346,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
314
346
 
315
347
  if (repairEscalate) {
316
348
  logger.warn(`HU ${story.id}: Repairer escalated — ${repairEscalate}`);
317
- return { approved: false, sessionId: ctx.session.id, reason: "repair_escalated" };
349
+ return { approved: false, sessionId: laneSession.id, reason: "repair_escalated" };
318
350
  }
319
351
  if (repairedAny) {
320
352
  // Tests were rewritten — restart iteration with the fixed
@@ -328,26 +360,24 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
328
360
  // No repair happened — normal coder feedback path.
329
361
  const diagnostic = buildDiagnosticPrompt(failed);
330
362
  logger.warn(`HU ${story.id}: ${failed.length} acceptance test(s) FAILED — sending diagnostic to coder`);
331
- setReviewerFeedback(ctx.session, diagnostic);
332
- ctx.plannedTask = `${huTask}\n\n--- ACCEPTANCE TEST FAILURES ---\n${diagnostic}`;
363
+ setReviewerFeedback(laneSession, diagnostic);
364
+ lanePlannedTask = `${huTask}\n\n--- ACCEPTANCE TEST FAILURES ---\n${diagnostic}`;
333
365
  }
334
366
 
335
367
  // All iterations exhausted
336
368
  logger.warn(`HU ${story.id}: max iterations reached with acceptance tests still failing`);
337
- return { approved: false, sessionId: ctx.session.id, reason: "acceptance_tests_failed" };
369
+ return { approved: false, sessionId: laneSession.id, reason: "acceptance_tests_failed" };
338
370
  }
339
371
 
340
- // Fallback: no acceptance_tests → standard pipeline (reviewer/tester)
341
- try {
342
- const result = await runIterationLoop(ctx, { task: huTask, askQuestion, emitter, logger });
343
- if (result?.approved) {
344
- await finalizeHuCommit({ story, branchName, config: ctx.config, logger });
345
- }
346
- return result;
347
- } finally {
348
- ctx.config.max_iterations = originalMaxIterations;
349
- Object.assign(ctx.pipelineFlags, savedFlags);
372
+ // Fallback: no acceptance_tests → standard pipeline (reviewer/tester).
373
+ // The loop reads everything through ctx.* — handing it a lane view
374
+ // keeps the shared ctx untouched (no more mutate-and-restore dance).
375
+ const laneCtx = { ...ctx, config: laneConfig, pipelineFlags: laneFlags, session: laneSession, brainCtx: laneBrain, plannedTask: lanePlannedTask };
376
+ const result = await runIterationLoop(laneCtx, { task: huTask, askQuestion, emitter, logger });
377
+ if (result?.approved) {
378
+ await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
350
379
  }
380
+ return result;
351
381
  },
352
382
  emitter,
353
383
  eventBase: ctx.eventBase,
@@ -6,7 +6,11 @@ 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 { join } from "node:path";
10
+ import { acquireSlot, releaseSlot } from "karajan-core/slot-registry";
11
+ import { getKarajanHome } from "karajan-core/paths";
9
12
  import { findParallelGroups, createWorktree, mergeWorktree, removeWorktree } from "../hu/parallel-executor.js";
13
+ import { bootstrapWorktree } from "../hu/worktree-bootstrap.js";
10
14
  import { partitionConflictFree } from "./hu-scheduler.js";
11
15
  import { createParallelLimiter, planBudgetUsd } from "./parallel-limiter.js";
12
16
 
@@ -200,7 +204,7 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudget
200
204
  * @param {object} params
201
205
  * @returns {Promise<{huId: string, approved: boolean, result?: object, error?: string, blockedDependents?: string[]}>}
202
206
  */
203
- async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath, onStatusChange = null, onOutcome = null, budgetTracker = null }) {
207
+ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath, laneSlot = null, onStatusChange = null, onOutcome = null, budgetTracker = null }) {
204
208
  // PR1 (live HU status): every saveHuBatch should also notify the
205
209
  // plan JSON so the board reflects state in real time. Defined as a
206
210
  // local helper so we don't repeat the try/null-check boilerplate.
@@ -285,7 +289,9 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
285
289
  }));
286
290
 
287
291
  try {
288
- const iterResult = await runIterationFn(huTask, story);
292
+ // PAR-E2 (KJC-TSK-0629): lanes handed a worktree must aim every git and
293
+ // filesystem touchpoint at it — laneOpts carries that path to the runner.
294
+ const iterResult = await runIterationFn(huTask, story, { worktreePath: worktreePath || null, laneSlot });
289
295
  const approved = Boolean(iterResult?.approved);
290
296
 
291
297
  // --- Transition to reviewing (post-coder, pre-reviewer evaluation) ---
@@ -525,12 +531,27 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
525
531
  // Multiple HUs: create worktrees, run in parallel
526
532
  const projectDir = config?.projectDir || process.cwd();
527
533
  const worktrees = new Map();
534
+ // PAR-H (KJC-TSK-0631): every lane gets a stable numeric slot so
535
+ // services it starts can offset their ports (KJ_LANE_SLOT /
536
+ // KJ_PORT_OFFSET reach the coder and the acceptance tests).
537
+ const slotRegistryPath = join(getKarajanHome(), "worktree-slots.json");
538
+ const laneSlots = new Map();
528
539
 
529
540
  // Create worktrees for each HU in the batch
530
541
  for (const id of runnableIds) {
531
542
  try {
532
543
  const wtPath = await createWorktree(projectDir, id);
533
544
  worktrees.set(id, wtPath);
545
+ // PAR-G (KJC-TSK-0630): a fresh worktree has no node_modules and
546
+ // no initialized submodules — make the lane operative before the
547
+ // coder lands. Best-effort: warnings never block the lane.
548
+ await bootstrapWorktree({ worktreePath: wtPath, setupCommand: config?.session?.worktree_setup || null, logger });
549
+ try {
550
+ const { slot } = await acquireSlot({ registryPath: slotRegistryPath, id: `${projectDir}::${id}` });
551
+ laneSlots.set(id, slot);
552
+ } catch (err) {
553
+ logger.warn(`Failed to acquire lane slot for HU ${id}: ${err.message} — lane runs without port offset`);
554
+ }
534
555
  } catch (err) {
535
556
  logger.warn(`Failed to create worktree for HU ${id}: ${err.message} — will run sequentially`);
536
557
  }
@@ -545,6 +566,7 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
545
566
  storyId, batch, batchSessionId, runIterationFn,
546
567
  emitter, eventBase, logger, config, results,
547
568
  worktreePath: worktrees.get(storyId),
569
+ laneSlot: laneSlots.get(storyId) ?? null,
548
570
  onStatusChange, onOutcome, budgetTracker
549
571
  });
550
572
  } finally {
@@ -560,6 +582,7 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
560
582
  if (res.approved && worktrees.has(res.huId)) {
561
583
  try {
562
584
  await mergeWorktree(projectDir, res.huId);
585
+ await releaseSlot({ registryPath: slotRegistryPath, id: `${projectDir}::${res.huId}` }).catch(() => {});
563
586
  } catch (err) {
564
587
  logger.warn(`Failed to merge worktree for HU ${res.huId}: ${err.message}`);
565
588
  }
@@ -579,6 +602,7 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
579
602
  // Clean up failed worktree
580
603
  if (worktrees.has(res.huId)) {
581
604
  try { await removeWorktree(projectDir, res.huId); } catch { /* ignore */ }
605
+ await releaseSlot({ registryPath: slotRegistryPath, id: `${projectDir}::${res.huId}` }).catch(() => {});
582
606
  }
583
607
  }
584
608
  }