karajan-code 3.13.1 → 3.14.1

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": "3.13.1",
3
+ "version": "3.14.1",
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",
@@ -101,6 +101,8 @@ export function registerPipeline(program, { pkgVersion }) {
101
101
  .option("--enable-sonarcloud", "Enable SonarCloud scan (complementary to SonarQube)")
102
102
  .option("--no-sonarcloud")
103
103
  .option("--checkpoint-interval <n>", "Minutes between interactive checkpoints (default: 5)")
104
+ .option("--step", "Pause after each iteration with a report and ask before continuing")
105
+ .option("--parallel <n>", "Concurrent HU lanes for plan runs (default: 1, sequential)")
104
106
  .option("--pg-task <cardId>", "Planning Game card ID (e.g., KJC-TSK-0042)")
105
107
  .option("--pg-project <projectId>", "Planning Game project ID")
106
108
  .option("--auto-simplify", "Auto-simplify pipeline for simple tasks (disable reviewer/tester)")
@@ -371,6 +371,15 @@ async function runWizard(config, logger) {
371
371
  logger.info("Git automation:");
372
372
  await askGitAutomation(wizard, config, logger);
373
373
 
374
+ // Iteration gate (KJC-TSK-0628): opt-in step mode, default no.
375
+ config.session = config.session || {};
376
+ const stepMode = await wizard.confirm(
377
+ "Pause after each iteration with a report and ask before continuing (step mode)?",
378
+ false,
379
+ );
380
+ config.session.iteration_gate = stepMode;
381
+ logger.info(` -> iteration_gate: ${stepMode}`);
382
+
374
383
  if (enableHuBoard) {
375
384
  logger.info("");
376
385
  logger.info("HU Board security:");
@@ -176,6 +176,12 @@ const DEFAULTS = {
176
176
  role_overrides: {}
177
177
  },
178
178
  session: {
179
+ // Opt-in per-iteration supervision (KJC-TSK-0628): pause after each
180
+ // iteration with a report and ask continue / stop / instructions.
181
+ iteration_gate: false,
182
+ // Concurrent HU lanes per plan run (KJC-TSK-0626). 1 = sequential.
183
+ // Raising it multiplies token burn rate — the plan budget scales with it.
184
+ max_parallel_hus: 1,
179
185
  max_iteration_minutes: 30,
180
186
  max_total_minutes: 120,
181
187
  max_planner_minutes: 60,
@@ -55,6 +55,7 @@ const SCALAR_FLAGS = [
55
55
  ["maxIterationMinutes", (out, v) => { out.session.max_iteration_minutes = Number(v); }],
56
56
  ["maxTotalMinutes", (out, v) => { out.session.max_total_minutes = Number(v); }],
57
57
  ["checkpointInterval", (out, v) => { out.session.checkpoint_interval_minutes = Number(v); }],
58
+ ["parallel", (out, v) => { out.session.max_parallel_hus = Number(v); }],
58
59
  ["baseBranch", (out, v) => { out.base_branch = v; }],
59
60
  ["coderFallback", (out, v) => { out.coder_options.fallback_coder = v; }],
60
61
  ["reviewerFallback", (out, v) => { out.reviewer_options.fallback_reviewer = v; }],
@@ -147,6 +148,9 @@ function applyMiscOverrides(out, flags) {
147
148
  // The flag is still accepted to avoid breaking scripts; it now emits a
148
149
  // warning at run start and is otherwise ignored. See preflight-checks.js
149
150
  // and flow-runner.js for the actual gate (resolvedPolicies.sonar).
151
+ // --step: per-iteration supervision gate (KJC-TSK-0628).
152
+ if (flags.step === true) out.session.iteration_gate = true;
153
+
150
154
  if (flags.noSonar || flags.sonar === false) {
151
155
  out._deprecated = out._deprecated || {};
152
156
  out._deprecated.noSonarFlag = true;
@@ -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}`);
@@ -27,6 +27,7 @@ import {
27
27
  setReviewerFeedback, resetRetryCount,
28
28
  } from "../../session/mutators.js";
29
29
  import { invokeSolomon } from "../solomon-escalation.js";
30
+ import { buildIterationReport, handleIterationGate } from "../iteration-gate.js";
30
31
  import {
31
32
  handleCiEarlyPrOrPush, handleCiReviewDispatch,
32
33
  } from "../ci-integration.js";
@@ -251,6 +252,21 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
251
252
  return iterResult.result;
252
253
  }
253
254
  if (iterResult.action === "retry") { i -= 1; }
255
+ else {
256
+ // Iteration gate (KJC-TSK-0628): opt-in pause with a report before the
257
+ // next iteration; free-text answers become directives for the coder.
258
+ const gate = await handleIterationGate({
259
+ enabled: ctx.config.session?.iteration_gate === true,
260
+ askQuestion, session: ctx.session, logger,
261
+ report: buildIterationReport({
262
+ i, maxIterations: ctx.config.max_iterations, stageResults: ctx.stageResults,
263
+ budgetTracker: ctx.budgetTracker, maxBudgetUsd: ctx.config.max_budget_usd,
264
+ }),
265
+ });
266
+ if (gate.action === "stop") {
267
+ return { approved: false, sessionId: ctx.session.id, reason: "user_stopped_at_gate", iteration: i };
268
+ }
269
+ }
254
270
  }
255
271
 
256
272
  // Solomon decides whether to extend iterations or stop
@@ -289,6 +305,20 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
289
305
  const iterResult = await runSingleIteration(ctx);
290
306
  if (iterResult.action === "return") return iterResult.result;
291
307
  if (iterResult.action === "retry") { i -= 1; }
308
+ else {
309
+ // Same iteration gate on Solomon-extended iterations (KJC-TSK-0628).
310
+ const gate = await handleIterationGate({
311
+ enabled: ctx.config.session?.iteration_gate === true,
312
+ askQuestion, session: ctx.session, logger,
313
+ report: buildIterationReport({
314
+ i, maxIterations: ctx.config.max_iterations, stageResults: ctx.stageResults,
315
+ budgetTracker: ctx.budgetTracker, maxBudgetUsd: ctx.config.max_budget_usd,
316
+ }),
317
+ });
318
+ if (gate.action === "stop") {
319
+ return { approved: false, sessionId: ctx.session.id, reason: "user_stopped_at_gate", iteration: i };
320
+ }
321
+ }
292
322
  }
293
323
 
294
324
  // Extended iterations also exhausted — final Solomon call
@@ -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" };
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { generateDiff } from "../../../review/diff-generator.js";
17
+ import { withLock } from "../../../utils/async-lock.js";
17
18
  import {
18
19
  runTddCheckStage, runSonarStage, runSonarCloudStage,
19
20
  } from "../../iteration-stages.js";
@@ -52,10 +53,12 @@ export async function runQualityGateStages({ config, logger, emitter, eventBase,
52
53
  // that one file.
53
54
  const sonarStageDisabledForTest = config?.testHarness?.disableSonarStage === true;
54
55
  if (!sonarStageDisabledForTest && session.resolved_policies?.sonar !== false) {
55
- const sonarResult = await runSonarStage({
56
+ // Serialize across parallel HU lanes (KJC-TSK-0625): the SonarQube
57
+ // server scans one project key at a time. No-op on sequential runs.
58
+ const sonarResult = await withLock("sonar-scan", () => runSonarStage({
56
59
  config, logger, emitter, eventBase, session, trackBudget, iteration: i,
57
60
  repeatDetector, budgetSummary, sonarState, askQuestion, task, brainCtx
58
- });
61
+ }));
59
62
  if (sonarResult.action === "stalled" || sonarResult.action === "pause") return { action: "return", result: sonarResult.result };
60
63
  if (sonarResult.action === "continue") return { action: "continue" };
61
64
  if (sonarResult.stageResult) {
@@ -74,7 +77,7 @@ export async function runQualityGateStages({ config, logger, emitter, eventBase,
74
77
  }
75
78
 
76
79
  if (pipelineFlags?.impeccableEnabled) {
77
- const diff = await generateDiff({ baseRef: session.session_start_sha });
80
+ const diff = await generateDiff({ baseRef: session.session_start_sha, projectDir: config?.projectDir || null });
78
81
  const impeccableMode = pipelineFlags?.impeccableMode || "audit";
79
82
  const impeccableResult = await runImpeccableStage({
80
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,50 @@ 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
+ const laneConfig = { ...ctx.config, projectDir, max_iterations: huMaxIterations };
108
+ if (!huPolicies.tdd) laneConfig.development = { ...laneConfig.development, methodology: "standard", require_test_changes: false };
109
+ if (!huPolicies.sonar) laneConfig.sonarqube = { ...laneConfig.sonarqube, enabled: false };
110
+ const laneFlags = { ...ctx.pipelineFlags };
111
+ if (!huPolicies.reviewer) laneFlags.reviewerEnabled = false;
112
+ if (!huPolicies.testsRequired) laneFlags.testerEnabled = false;
113
+ // Worktree lanes clone the session so reviewer feedback never leaks
114
+ // across concurrent HUs. Disk saves still share the session id (last
115
+ // writer wins) — journal noise at most; in-memory isolation is what
116
+ // guards correctness.
117
+ const laneSession = worktreePath ? { ...ctx.session } : ctx.session;
118
+ let laneBrain = ctx.brainCtx;
119
+ if (ctx.brainCtx?.enabled) {
120
+ const { createBrainContext } = await import("../brain-coordinator.js");
121
+ const fresh = createBrainContext({ enabled: true });
122
+ if (worktreePath) {
123
+ laneBrain = { ...ctx.brainCtx, extensionCount: 0, feedbackQueue: fresh.feedbackQueue, verificationTracker: fresh.verificationTracker };
124
+ } else {
125
+ ctx.brainCtx.extensionCount = 0;
126
+ ctx.brainCtx.feedbackQueue = fresh.feedbackQueue;
127
+ ctx.brainCtx.verificationTracker = fresh.verificationTracker;
128
+ }
129
+ }
130
+ let lanePlannedTask = ctx.plannedTask;
131
+
132
+ // A worktree lane skips the main-tree checkout: `git worktree add -b`
133
+ // already left the worktree checked out on kj-hu-<id>.
134
+ let branchName;
135
+ if (worktreePath) {
136
+ branchName = `kj-hu-${story.id}`;
137
+ huBranches.set(story.id, branchName);
138
+ } else {
139
+ branchName = await prepareHuBranch({ story, huBranches, config: laneConfig, logger });
140
+ }
115
141
 
116
142
  // KJC-TSK-0408 step 2: snapshot del workspace ANTES de invocar al
117
143
  // coder. Si el usuario hace Undo después, restaura este SHA. El
@@ -142,11 +168,11 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
142
168
  // iterations against an irreparable test (the 2026-04-29 bug).
143
169
  const failureTracker = createFailureTracker();
144
170
 
145
- for (let attempt = 1; attempt <= ctx.config.max_iterations; attempt++) {
146
- logger.info(`HU ${story.id}: coder iteration ${attempt}/${ctx.config.max_iterations}`);
171
+ for (let attempt = 1; attempt <= laneConfig.max_iterations; attempt++) {
172
+ logger.info(`HU ${story.id}: coder iteration ${attempt}/${laneConfig.max_iterations}`);
147
173
  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 }
174
+ message: `Iteration ${attempt}/${laneConfig.max_iterations}`,
175
+ detail: { iteration: attempt, maxIterations: laneConfig.max_iterations }
150
176
  }));
151
177
 
152
178
  // Coder runs with the HU task + any diagnostic feedback from previous attempt.
@@ -155,9 +181,9 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
155
181
  // not only after the first failed run.
156
182
  const coderResult = await runCoderStage({
157
183
  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,
184
+ config: laneConfig, logger, emitter, eventBase: ctx.eventBase,
185
+ session: laneSession, plannedTask: lanePlannedTask,
186
+ trackBudget: ctx.trackBudget, iteration: attempt, brainCtx: laneBrain,
161
187
  acceptanceTests: story.acceptance_tests,
162
188
  // PR F (v2.7.5): plan-aware coder context. ADRs are
163
189
  // shared across the plan, the rest are scoped to this
@@ -173,11 +199,11 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
173
199
  }
174
200
 
175
201
  // Sonar quality gate (sw task_type only, when policies say sonar=true)
176
- if (huPolicies.sonar && ctx.config.sonarqube?.enabled) {
202
+ if (huPolicies.sonar && laneConfig.sonarqube?.enabled) {
177
203
  try {
178
204
  const sonarResult = await runSonarStage({
179
- config: ctx.config, logger, emitter, eventBase: ctx.eventBase,
180
- session: ctx.session, trackBudget: ctx.trackBudget, iteration: attempt,
205
+ config: laneConfig, logger, emitter, eventBase: ctx.eventBase,
206
+ session: laneSession, trackBudget: ctx.trackBudget, iteration: attempt,
181
207
  // sonarState is required: runSonarStage reads/writes
182
208
  // .issuesInitial / .issuesFinal on it. Forgetting this
183
209
  // surfaces as `Cannot read properties of undefined
@@ -188,11 +214,11 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
188
214
  sonarState: ctx.sonarState,
189
215
  repeatDetector: ctx.repeatDetector,
190
216
  budgetSummary: ctx.budgetSummary,
191
- askQuestion, brainCtx: ctx.brainCtx
217
+ askQuestion, brainCtx: laneBrain
192
218
  });
193
219
  if (sonarResult?.action === "continue") {
194
220
  // Sonar failed — add to feedback for next coder attempt
195
- ctx.plannedTask = `${huTask}\n\n--- SONAR FAILURE ---\n${ctx.session.last_reviewer_feedback}`;
221
+ lanePlannedTask = `${huTask}\n\n--- SONAR FAILURE ---\n${laneSession.last_reviewer_feedback}`;
196
222
  continue;
197
223
  }
198
224
  } catch (err) {
@@ -225,8 +251,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
225
251
  );
226
252
  if (pendingGherkinTests.length === 0) {
227
253
  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" };
254
+ await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
255
+ return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed" };
230
256
  }
231
257
 
232
258
  // Shell tests passed; Gherkin needs translation. Invoke the
@@ -236,16 +262,16 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
236
262
  const { runTesterStage } = await import("../post-loop-stages.js");
237
263
  const shellResults = testResult.results.filter((r) => r.type !== "gherkin");
238
264
  const testerOutcome = await runTesterStage({
239
- config: ctx.config, logger, emitter, eventBase: ctx.eventBase,
240
- session: ctx.session, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget,
265
+ config: laneConfig, logger, emitter, eventBase: ctx.eventBase,
266
+ session: laneSession, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget,
241
267
  iteration: attempt, task: huTask, diff: null, askQuestion,
242
268
  pendingGherkinTests, shellTestResults: shellResults,
243
269
  });
244
270
  const testerStage = testerOutcome?.stageResult;
245
271
  if (testerOutcome?.action !== "continue" && testerStage?.verdict === "pass") {
246
272
  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" };
273
+ await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
274
+ return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed" };
249
275
  }
250
276
 
251
277
  // Tester rejected (translated tests failed or coverage
@@ -259,8 +285,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
259
285
  "Fix the implementation so every scenario passes. Do NOT soften the scenarios.",
260
286
  ].filter(Boolean).join("\n");
261
287
  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}`;
288
+ setReviewerFeedback(laneSession, diagnostic);
289
+ lanePlannedTask = `${huTask}\n\n--- GHERKIN TRANSLATION FAILURES ---\n${diagnostic}`;
264
290
  continue;
265
291
  }
266
292
 
@@ -314,7 +340,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
314
340
 
315
341
  if (repairEscalate) {
316
342
  logger.warn(`HU ${story.id}: Repairer escalated — ${repairEscalate}`);
317
- return { approved: false, sessionId: ctx.session.id, reason: "repair_escalated" };
343
+ return { approved: false, sessionId: laneSession.id, reason: "repair_escalated" };
318
344
  }
319
345
  if (repairedAny) {
320
346
  // Tests were rewritten — restart iteration with the fixed
@@ -328,26 +354,24 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
328
354
  // No repair happened — normal coder feedback path.
329
355
  const diagnostic = buildDiagnosticPrompt(failed);
330
356
  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}`;
357
+ setReviewerFeedback(laneSession, diagnostic);
358
+ lanePlannedTask = `${huTask}\n\n--- ACCEPTANCE TEST FAILURES ---\n${diagnostic}`;
333
359
  }
334
360
 
335
361
  // All iterations exhausted
336
362
  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" };
363
+ return { approved: false, sessionId: laneSession.id, reason: "acceptance_tests_failed" };
338
364
  }
339
365
 
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);
366
+ // Fallback: no acceptance_tests → standard pipeline (reviewer/tester).
367
+ // The loop reads everything through ctx.* — handing it a lane view
368
+ // keeps the shared ctx untouched (no more mutate-and-restore dance).
369
+ const laneCtx = { ...ctx, config: laneConfig, pipelineFlags: laneFlags, session: laneSession, brainCtx: laneBrain, plannedTask: lanePlannedTask };
370
+ const result = await runIterationLoop(laneCtx, { task: huTask, askQuestion, emitter, logger });
371
+ if (result?.approved) {
372
+ await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
350
373
  }
374
+ return result;
351
375
  },
352
376
  emitter,
353
377
  eventBase: ctx.eventBase,
@@ -0,0 +1,114 @@
1
+ /**
2
+ * hu-scheduler — pure scheduling for parallel HU execution (KJC-TSK-0623,
3
+ * épica KJC-PCS-0065). Decides WHICH HUs may run concurrently; it never
4
+ * launches anything. Two rules, both conservative by design:
5
+ *
6
+ * 1. Dependencies: every blocked_by id must be in completedIds.
7
+ * 2. Scope isolation: two HUs whose scopes touch the same path prefix are
8
+ * never scheduled together — predictable merge conflicts cost more than
9
+ * the parallelism they'd buy. An HU with NO scope has an unknown blast
10
+ * radius, so it runs alone (exclusive with everything).
11
+ */
12
+
13
+ /**
14
+ * Extract path-like tokens from a free-form scope. The planner writes scope
15
+ * as prose ("src/auth y tests/auth", "docs/*.md"); we keep anything that
16
+ * looks like a path or glob, normalized to its base directory.
17
+ */
18
+ export function scopeTokens(scope) {
19
+ if (!scope || typeof scope !== "string") return [];
20
+ const matches = scope.match(/[\w.-]+(?:\/[\w*.{}-]+)+|[\w.-]+\/(?=\s|$|,)/g) || [];
21
+ return matches
22
+ .map((raw) => {
23
+ // Cut the token at its first glob segment: "src/**/*.js" → "src".
24
+ const segments = raw.replace(/\/+$/, "").split("/");
25
+ const firstGlob = segments.findIndex((s) => s.includes("*") || s.includes("{"));
26
+ return (firstGlob === -1 ? segments : segments.slice(0, firstGlob)).join("/");
27
+ })
28
+ .filter(Boolean);
29
+ }
30
+
31
+ /** True when two token lists share a path-prefix relationship. */
32
+ function tokensOverlap(a, b) {
33
+ for (const ta of a) {
34
+ for (const tb of b) {
35
+ if (ta === tb || ta.startsWith(`${tb}/`) || tb.startsWith(`${ta}/`)) return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * True when two HUs cannot run concurrently: either one has no parseable
43
+ * scope (unknown blast radius ⇒ exclusive) or their scopes overlap.
44
+ */
45
+ export function husConflict(huA, huB) {
46
+ const a = scopeTokens(huA.scope);
47
+ const b = scopeTokens(huB.scope);
48
+ if (a.length === 0 || b.length === 0) return true;
49
+ return tokensOverlap(a, b);
50
+ }
51
+
52
+ /**
53
+ * Split one dependency-level group into conflict-free chunks of at most
54
+ * `maxParallel` HUs (KJC-TSK-0626). Every chunk runs concurrently; chunks
55
+ * run one after another. maxParallel 1 ⇒ singletons (fully sequential).
56
+ * Same conservative rules as husConflict: no parseable scope ⇒ runs alone.
57
+ *
58
+ * @param {Array<{id: string, scope?: string|null}>} stories - full story list
59
+ * @param {string[]} groupIds - ids of one dependency-level group, in order
60
+ * @param {number} maxParallel
61
+ * @returns {string[][]} chunks of ids
62
+ */
63
+ export function partitionConflictFree(stories, groupIds, maxParallel = 1) {
64
+ if (maxParallel <= 1) return groupIds.map((id) => [id]);
65
+ const byId = new Map(stories.map((s) => [s.id, s]));
66
+ const chunks = [];
67
+ for (const id of groupIds) {
68
+ const story = byId.get(id) ?? { id };
69
+ const fit = chunks.find(
70
+ (chunk) => chunk.length < maxParallel && !chunk.some((otherId) => husConflict(story, byId.get(otherId) ?? { id: otherId })),
71
+ );
72
+ if (fit) fit.push(id);
73
+ else chunks.push([id]);
74
+ }
75
+ return chunks;
76
+ }
77
+
78
+ /**
79
+ * Select the next HUs to launch, order-preserving and greedy.
80
+ *
81
+ * @param {object} args
82
+ * @param {Array<{id: string, blocked_by?: string[], scope?: string|null}>} args.hus - candidates, in plan order
83
+ * @param {string[]} [args.completedIds] - HUs already done (dependency source)
84
+ * @param {Array<{id: string, scope?: string|null}>} [args.inFlight] - HUs currently running
85
+ * @param {number} [args.maxParallel] - total concurrency cap (in-flight included)
86
+ * @returns {{ selected: object[], blocked: Array<{id: string, reason: string}> }}
87
+ */
88
+ export function selectRunnableHus({ hus = [], completedIds = [], inFlight = [], maxParallel = 1 } = {}) {
89
+ const done = new Set(completedIds);
90
+ const flying = new Set(inFlight.map((h) => h.id));
91
+ const slots = Math.max(0, maxParallel - inFlight.length);
92
+ const selected = [];
93
+ const blocked = [];
94
+
95
+ for (const hu of hus) {
96
+ if (flying.has(hu.id) || done.has(hu.id)) continue;
97
+ const missing = (hu.blocked_by || []).filter((dep) => !done.has(dep));
98
+ if (missing.length > 0) {
99
+ blocked.push({ id: hu.id, reason: `waiting for ${missing.join(", ")}` });
100
+ continue;
101
+ }
102
+ if (selected.length >= slots) {
103
+ blocked.push({ id: hu.id, reason: "no free slot" });
104
+ continue;
105
+ }
106
+ const rival = [...inFlight, ...selected].find((other) => husConflict(hu, other));
107
+ if (rival) {
108
+ blocked.push({ id: hu.id, reason: `scope conflicts with ${rival.id}` });
109
+ continue;
110
+ }
111
+ selected.push(hu);
112
+ }
113
+ return { selected, blocked };
114
+ }
@@ -7,6 +7,8 @@ import { updateStoryStatus, loadHuBatch, saveHuBatch, HU_STATUS } from "../hu/st
7
7
  import { emitProgress, makeEvent } from "../utils/events.js";
8
8
  import { refineHuWithContext } from "../hu/lazy-planner.js";
9
9
  import { findParallelGroups, createWorktree, mergeWorktree, removeWorktree } from "../hu/parallel-executor.js";
10
+ import { partitionConflictFree } from "./hu-scheduler.js";
11
+ import { createParallelLimiter, planBudgetUsd } from "./parallel-limiter.js";
10
12
 
11
13
  /**
12
14
  * Determine whether the HU reviewer result needs the sub-pipeline path.
@@ -283,7 +285,9 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
283
285
  }));
284
286
 
285
287
  try {
286
- const iterResult = await runIterationFn(huTask, story);
288
+ // PAR-E2 (KJC-TSK-0629): lanes handed a worktree must aim every git and
289
+ // filesystem touchpoint at it — laneOpts carries that path to the runner.
290
+ const iterResult = await runIterationFn(huTask, story, { worktreePath: worktreePath || null });
287
291
  const approved = Boolean(iterResult?.approved);
288
292
 
289
293
  // --- Transition to reviewing (post-coder, pre-reviewer evaluation) ---
@@ -459,15 +463,38 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
459
463
  // --- Group HUs into parallel batches ---
460
464
  const parallelBatches = findParallelGroups(certifiedStories, orderedIds);
461
465
 
462
- for (const group of parallelBatches) {
466
+ // Governance (KJC-TSK-0626): the dependency groups above used to launch
467
+ // UNBOUNDED via Promise.all — a plan with N independent HUs ran N full
468
+ // pipelines at once, with no cap, no shared budget and no scope checks.
469
+ // Now each group is partitioned into conflict-free chunks of at most
470
+ // max_parallel_hus (default 1 = fully sequential, today's safe path) and
471
+ // a shared limiter gates every launch against the PLAN budget.
472
+ const maxParallel = Math.max(1, Number(config?.session?.max_parallel_hus ?? 1));
473
+ const limiter = createParallelLimiter({
474
+ maxParallel,
475
+ budgetTracker,
476
+ planBudgetUsd: planBudgetUsd({ maxBudgetUsd: config?.max_budget_usd, maxParallel }),
477
+ });
478
+ let stopReason = null;
479
+
480
+ outer: for (const group of parallelBatches) {
481
+ for (const chunk of partitionConflictFree(batch.stories, group, maxParallel)) {
463
482
  // Filter out HUs that were blocked by a failed dependency in a previous batch
464
- const runnableIds = group.filter(id => {
483
+ const runnableIds = chunk.filter(id => {
465
484
  const story = batch.stories.find(s => s.id === id);
466
485
  return story && story.status !== HU_STATUS.BLOCKED;
467
486
  });
468
487
 
469
488
  if (runnableIds.length === 0) continue;
470
489
 
490
+ const blockReason = limiter.launchBlockReason();
491
+ if (blockReason) {
492
+ stopReason = blockReason;
493
+ allApproved = false;
494
+ logger.warn(`HU sub-pipeline stopped before launching ${runnableIds.join(", ")}: ${blockReason}`);
495
+ break outer;
496
+ }
497
+
471
498
  // Emit parallel batch start event
472
499
  emitProgress(emitter, makeEvent("hu:parallel-start", { ...eventBase, stage: "hu-sub-pipeline" }, {
473
500
  message: `Starting parallel batch of ${runnableIds.length} HU(s): ${runnableIds.join(", ")}`,
@@ -511,14 +538,20 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
511
538
  }
512
539
  }
513
540
 
514
- // Run all HUs in the batch concurrently
541
+ // Run all HUs in the batch concurrently, each holding a limiter slot
542
+ // (belt-and-braces: chunks are already ≤ maxParallel).
515
543
  const batchPromises = runnableIds.map(async (storyId) => {
516
- return runSingleHu({
517
- storyId, batch, batchSessionId, runIterationFn,
518
- emitter, eventBase, logger, config, results,
519
- worktreePath: worktrees.get(storyId),
520
- onStatusChange, onOutcome, budgetTracker
521
- });
544
+ await limiter.acquire();
545
+ try {
546
+ return await runSingleHu({
547
+ storyId, batch, batchSessionId, runIterationFn,
548
+ emitter, eventBase, logger, config, results,
549
+ worktreePath: worktrees.get(storyId),
550
+ onStatusChange, onOutcome, budgetTracker
551
+ });
552
+ } finally {
553
+ limiter.release();
554
+ }
522
555
  });
523
556
 
524
557
  const batchResults = await Promise.all(batchPromises);
@@ -554,7 +587,8 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
554
587
 
555
588
  await saveHuBatch(batchSessionId, batch);
556
589
  }
590
+ }
557
591
  }
558
592
 
559
- return { approved: allApproved, results, blockedIds };
593
+ return { approved: allApproved, results, blockedIds, ...(stopReason ? { stopReason } : {}) };
560
594
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * iteration-gate — opt-in per-iteration supervision (KJC-TSK-0628, épica
3
+ * KJC-PCS-0062). With `--step` (or session.iteration_gate in config) the
4
+ * pipeline pauses after EVERY iteration with a compact report — what
5
+ * happened, what the next iteration will do, spend vs cap — and asks the
6
+ * user: continue, stop, or continue WITH instructions. Free-text answers
7
+ * are appended to the reviewer feedback the coder reads next iteration
8
+ * (same channel Solomon's human guidance uses), never overwriting the
9
+ * reviewer's own must-fix list.
10
+ */
11
+
12
+ import { markSessionStatus } from "../session/store.js";
13
+ import { setReviewerFeedback } from "../session/mutators.js";
14
+
15
+ const STOP_ANSWERS = new Set(["stop", "parar", "para", "no", "n", "4"]);
16
+ const CONTINUE_ANSWERS = new Set(["", "s", "si", "sí", "y", "yes", "1", "continue", "continuar"]);
17
+
18
+ /** Compact human report of the iteration that just finished. */
19
+ export function buildIterationReport({ i, maxIterations, stageResults = {}, budgetTracker, maxBudgetUsd, reviewVerdict }) {
20
+ const lines = [`Iteration ${i}/${maxIterations} finished.`];
21
+ const stages = Object.keys(stageResults);
22
+ if (stages.length > 0) lines.push(`Stages so far: ${stages.join(", ")}`);
23
+
24
+ const review = reviewVerdict ?? stageResults.reviewer ?? null;
25
+ const mustFix = review?.must_fix ?? review?.issues ?? [];
26
+ if (review?.approved === true) {
27
+ lines.push("Reviewer: APPROVED");
28
+ } else if (mustFix.length > 0) {
29
+ const shown = mustFix.slice(0, 3).map((f) => ` • ${typeof f === "string" ? f : f.title || f.description || JSON.stringify(f)}`);
30
+ lines.push(`Reviewer: rejected with ${mustFix.length} must-fix:`, ...shown);
31
+ if (mustFix.length > 3) lines.push(` … and ${mustFix.length - 3} more`);
32
+ lines.push(`Next iteration: the coder tackles those ${mustFix.length} must-fix.`);
33
+ } else {
34
+ lines.push("Next iteration: the coder retries against the pending feedback.");
35
+ }
36
+
37
+ const spent = budgetTracker?.total?.().cost_usd ?? 0;
38
+ const cap = maxBudgetUsd != null ? ` / cap $${Number(maxBudgetUsd).toFixed(2)}` : "";
39
+ lines.push(`Spend: $${spent.toFixed(2)}${cap}`);
40
+ return lines.join("\n");
41
+ }
42
+
43
+ /** Append a user directive without clobbering the reviewer's feedback. */
44
+ export function appendUserDirective(session, text) {
45
+ const existing = session.last_reviewer_feedback || "";
46
+ const block = `## User directive (iteration gate)\n${text}`;
47
+ setReviewerFeedback(session, existing ? `${existing}\n\n${block}` : block);
48
+ }
49
+
50
+ /**
51
+ * Ask the user at the gate. Returns { action: "continue" | "stop",
52
+ * injected?: string }. Disabled gate, missing askQuestion (nobody to ask)
53
+ * or unattended autonomous runs pass straight through.
54
+ */
55
+ export async function handleIterationGate({ enabled, askQuestion, session, logger, report }) {
56
+ if (!enabled || !askQuestion || askQuestion.unattended === true) return { action: "continue" };
57
+
58
+ const answer = await askQuestion(
59
+ `${report}\n\nContinue? [Enter = yes | "stop" = stop | anything else = instructions for the next iteration]`,
60
+ );
61
+ const trimmed = (answer || "").trim();
62
+ const lower = trimmed.toLowerCase();
63
+
64
+ if (STOP_ANSWERS.has(lower)) {
65
+ await markSessionStatus(session, "stopped");
66
+ return { action: "stop" };
67
+ }
68
+ if (CONTINUE_ANSWERS.has(lower)) return { action: "continue" };
69
+
70
+ appendUserDirective(session, trimmed);
71
+ logger?.info?.("Iteration gate: user directive injected for the next iteration.");
72
+ return { action: "continue", injected: trimmed };
73
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * parallel-limiter — shared concurrency coordinator for parallel HU
3
+ * execution (KJC-TSK-0624, épica KJC-PCS-0065). One instance per plan run,
4
+ * shared by every HU lane:
5
+ *
6
+ * - Semaphore: at most maxParallel HUs hold a slot (acquire/release).
7
+ * - Plan budget: the whole run shares ONE BudgetTracker; the launch gate
8
+ * refuses new lanes once aggregated spend reaches the plan ceiling
9
+ * (maxParallel × max_budget_usd — parallelism scales the cap, it never
10
+ * multiplies it silently).
11
+ * - Cooldown: when any lane hits a provider rate limit it calls
12
+ * notifyCooldown(ms); NEW launches pause until it expires while lanes
13
+ * already flying finish their work — parallel retry stampedes are the
14
+ * fastest way to burn a quota.
15
+ */
16
+
17
+ export function createParallelLimiter({ maxParallel = 1, budgetTracker = null, planBudgetUsd = null, now = Date.now } = {}) {
18
+ let inUse = 0;
19
+ let cooldownUntil = 0;
20
+ const waiters = [];
21
+
22
+ const overPlanBudget = () => {
23
+ if (planBudgetUsd == null || !budgetTracker) return false;
24
+ return (budgetTracker.total?.().cost_usd ?? 0) >= Number(planBudgetUsd);
25
+ };
26
+
27
+ const grantNext = () => {
28
+ while (waiters.length > 0 && inUse < maxParallel) {
29
+ inUse += 1;
30
+ waiters.shift()();
31
+ }
32
+ };
33
+
34
+ return {
35
+ /** Why a new lane may not launch right now; null = clear to go. */
36
+ launchBlockReason() {
37
+ if (overPlanBudget()) {
38
+ return `plan budget exhausted ($${(budgetTracker.total().cost_usd).toFixed(2)} ≥ $${Number(planBudgetUsd).toFixed(2)})`;
39
+ }
40
+ const waitMs = cooldownUntil - now();
41
+ if (waitMs > 0) return `provider cooldown for ${Math.ceil(waitMs / 1000)}s`;
42
+ return null;
43
+ },
44
+
45
+ /** Wait for a free slot. Resolves when this lane may run. */
46
+ acquire() {
47
+ if (inUse < maxParallel) {
48
+ inUse += 1;
49
+ return Promise.resolve();
50
+ }
51
+ return new Promise((resolve) => waiters.push(resolve));
52
+ },
53
+
54
+ release() {
55
+ inUse = Math.max(0, inUse - 1);
56
+ grantNext();
57
+ },
58
+
59
+ /** A lane hit a rate limit: pause NEW launches until it expires. */
60
+ notifyCooldown(ms) {
61
+ cooldownUntil = Math.max(cooldownUntil, now() + Math.max(0, ms));
62
+ },
63
+
64
+ stats() {
65
+ return { inUse, waiting: waiters.length, maxParallel, cooldownUntil };
66
+ },
67
+ };
68
+ }
69
+
70
+ /** Plan-level ceiling: parallelism scales the per-run cap, explicitly. */
71
+ export function planBudgetUsd({ maxBudgetUsd, maxParallel = 1 }) {
72
+ if (maxBudgetUsd == null) return null;
73
+ return Number(maxBudgetUsd) * Math.max(1, maxParallel);
74
+ }
@@ -68,6 +68,9 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
68
68
  // the plan, the rest are scoped to this HU. All four pass through
69
69
  // untouched when the caller omits them (legacy single-task runs).
70
70
  adrs, specSection, reviewerFindings, huId,
71
+ // PAR-E2 (KJC-TSK-0629): the stage's config wins over the role's own —
72
+ // worktree lanes pass a laneConfig whose projectDir is the worktree.
73
+ projectDir: config?.projectDir || null,
71
74
  onOutput: coderStall.onOutput,
72
75
  // Lets Brain Recovery persist a standby snapshot if the coder's
73
76
  // provider hits a quota cap mid-run (KJC hibernation wiring).
@@ -429,8 +432,10 @@ export async function runTddCheckStage({ config, logger, emitter, eventBase, ses
429
432
  logger.setContext({ iteration, stage: "tdd" });
430
433
  let tddDiff, untrackedFiles;
431
434
  try {
432
- tddDiff = await generateDiff({ baseRef: session.session_start_sha });
433
- untrackedFiles = await getUntrackedFiles();
435
+ // PAR-E2 (KJC-TSK-0629): diff where the coder actually worked — a
436
+ // worktree lane's changes are invisible from the main tree.
437
+ tddDiff = await generateDiff({ baseRef: session.session_start_sha, projectDir: config?.projectDir || null });
438
+ untrackedFiles = await getUntrackedFiles(config?.projectDir || null);
434
439
  } catch (err) {
435
440
  logger.warn(`TDD diff generation failed: ${err.message}`);
436
441
  return { action: "continue", stageResult: { ok: false, summary: `TDD check failed: ${err.message}` } };
@@ -194,14 +194,14 @@ async function handleReviewerRejection({ review, repeatDetector, config, logger,
194
194
  });
195
195
  }
196
196
 
197
- export async function fetchReviewDiff(session, logger) {
197
+ export async function fetchReviewDiff(session, logger, projectDir = null) {
198
198
  let diff;
199
199
  if (session.ci_pr_number) {
200
200
  const { getPrDiff } = await import("../../ci/pr-diff.js");
201
201
  diff = await getPrDiff(session.ci_pr_number);
202
202
  logger.info(`Reviewer reading PR diff #${session.ci_pr_number}`);
203
203
  } else {
204
- diff = await generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true });
204
+ diff = await generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true, projectDir });
205
205
  }
206
206
  // Inbound boundary: mask hardcoded secrets before the diff reaches the
207
207
  // (possibly cloud) reviewer model. Runs BEFORE the injection guard below so
@@ -221,7 +221,7 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
221
221
 
222
222
  let diff;
223
223
  try {
224
- diff = await fetchReviewDiff(session, logger);
224
+ diff = await fetchReviewDiff(session, logger, config?.projectDir || null);
225
225
  } catch (err) {
226
226
  logger.warn(`Review diff generation failed: ${err.message}`);
227
227
  return { approved: false, blocking_issues: [{ description: `Diff generation failed: ${err.message}` }], non_blocking_suggestions: [], summary: `Reviewer failed: cannot generate diff — ${err.message}`, confidence: 0 };
@@ -24,6 +24,10 @@ export function planToHuBatch(plan) {
24
24
  task_type: hu.task_type || "sw",
25
25
  status: hu.status === "certified" ? "certified" : hu.status,
26
26
  blocked_by: hu.blocked_by || [],
27
+ // KJC-BUG-0110: scope must survive as its own field — the parallel
28
+ // scheduler treats a scopeless story as exclusive, so dropping it
29
+ // silently degrades --parallel N to sequential.
30
+ scope: hu.scope || null,
27
31
  certified: { text: hu.scope || hu.title },
28
32
  acceptance_criteria: hu.acceptance_criteria || [],
29
33
  acceptance_tests: hu.acceptance_tests || [],
@@ -88,6 +88,9 @@ export async function prependPreflightHu(plan, projectDir) {
88
88
  title: "[PREFLIGHT-000] Verificar entorno de desarrollo listo",
89
89
  description: "[PREFLIGHT-000] Block functional HUs until the project's environment is reproducible: deps installed, build/test/lint passing, git tree clean, cloud auth ready when applicable.",
90
90
  task_type: "infra",
91
+ // KJC-BUG-0108: without an explicit status, `kj plan ready` rejects the
92
+ // whole plan with "invalid status undefined".
93
+ status: "pending",
91
94
  blocked_by: [],
92
95
  reuse: [],
93
96
  acceptance_tests: composePreflightTests(stack, projectDir),
@@ -105,8 +105,8 @@ export async function generateDiff({ baseRef, stageNewFiles = false, projectDir
105
105
  return generateSnapshotDiff(emptySnapshot, null, dir);
106
106
  }
107
107
 
108
- export async function getUntrackedFiles() {
109
- const result = await run("git", ["ls-files", "--others", "--exclude-standard"]);
108
+ export async function getUntrackedFiles(cwd = null) {
109
+ const result = await run("git", ["ls-files", "--others", "--exclude-standard"], cwd ? { cwd } : {});
110
110
  if (result.exitCode !== 0) return [];
111
111
  return result.stdout.trim().split("\n").filter(Boolean);
112
112
  }
@@ -31,6 +31,7 @@ export class CoderRole extends AgentRole {
31
31
  reviewerFeedback: null, sonarSummary: null, deferredContext: null,
32
32
  acceptanceTests: null,
33
33
  adrs: null, specSection: null, reviewerFindings: null, huId: null,
34
+ projectDir: null,
34
35
  onOutput: null
35
36
  };
36
37
  }
@@ -52,12 +53,15 @@ export class CoderRole extends AgentRole {
52
53
  specSection: input?.specSection || null,
53
54
  reviewerFindings: input?.reviewerFindings || null,
54
55
  huId: input?.huId || null,
56
+ // PAR-E2 (KJC-TSK-0629): per-call project root. Worktree lanes pass
57
+ // their lane dir here; the shared role instance keeps its own config.
58
+ projectDir: input?.projectDir || null,
55
59
  onOutput: input?.onOutput || null
56
60
  };
57
61
  }
58
62
 
59
- async buildPrompt({ task, reviewerFeedback, sonarSummary, deferredContext, acceptanceTests, adrs, specSection, reviewerFindings, huId }) {
60
- const projectDir = this.config?.projectDir || null;
63
+ async buildPrompt({ task, reviewerFeedback, sonarSummary, deferredContext, acceptanceTests, adrs, specSection, reviewerFindings, huId, projectDir: laneProjectDir }) {
64
+ const projectDir = laneProjectDir || this.config?.projectDir || null;
61
65
  // KJC sesgo-de-stack: pasar stack/testFramework al prompt para que
62
66
  // el coder no asuma JS/vitest en proyectos Python / Go / Rust /
63
67
  // Ruby. audit-role.js y tester-role.js ya lo hacen así.
@@ -70,7 +70,11 @@ export async function runSpecReview({ spec, config, logger, askQuestion, flags =
70
70
  if (!askQuestion) return { proceed: true, severity, findings, finalSpec: currentSpec };
71
71
  const answer = await askQuestion(
72
72
  `Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default: continue)`,
73
- { detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1 } },
73
+ {
74
+ detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1 },
75
+ // KJC-BUG-0109: mirrors the interactive default — --yes presses Enter.
76
+ defaultAnswer: "continue",
77
+ },
74
78
  );
75
79
  if (answer === null) return { proceed: false, cancelled: true, severity, findings };
76
80
  const n = String(answer).trim().toLowerCase();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * async-lock — named in-process mutex (KJC-TSK-0625, épica KJC-PCS-0065).
3
+ * Parallel HU lanes run inside ONE kj process; shared single-instance
4
+ * resources (the SonarQube server scans one project key at a time) must
5
+ * serialize across lanes. `withLock` chains callers per name in FIFO
6
+ * order; sequential runs pay nothing (the chain is always settled).
7
+ */
8
+
9
+ const locks = new Map(); // name -> { tail: Promise, holders: number }
10
+
11
+ /** Run `fn` exclusively among every caller sharing `name`. */
12
+ export async function withLock(name, fn) {
13
+ const entry = locks.get(name) ?? { tail: Promise.resolve(), holders: 0 };
14
+ entry.holders += 1;
15
+ locks.set(name, entry);
16
+
17
+ const previous = entry.tail;
18
+ let release;
19
+ entry.tail = new Promise((resolve) => {
20
+ release = resolve;
21
+ });
22
+
23
+ await previous;
24
+ try {
25
+ return await fn();
26
+ } finally {
27
+ release();
28
+ entry.holders -= 1;
29
+ if (entry.holders === 0) locks.delete(name);
30
+ }
31
+ }
@@ -30,6 +30,15 @@ export function createCliAskQuestion(opts = {}) {
30
30
  const isInteractive = Boolean(process.stdin?.isTTY) && stdinReadable;
31
31
  if (!isInteractive) {
32
32
  if (flags?.yes) {
33
+ // KJC-BUG-0109: a question that declares a safe default is answered
34
+ // with it — --yes means "press Enter for me", not "abort on any
35
+ // gate". Questions without a declared default keep failing loud.
36
+ const fallback = typeof context?.defaultAnswer === "string" && context.defaultAnswer.trim()
37
+ ? context.defaultAnswer.trim() : null;
38
+ if (fallback) {
39
+ process.stderr.write(`\n[non-interactive --yes] Auto-answering with declared default "${fallback}": ${question}\n`);
40
+ return fallback;
41
+ }
33
42
  process.stderr.write(
34
43
  `\n[non-interactive --yes] Pipeline asked a question that cannot be auto-answered:\n`
35
44
  + ` ❓ ${question}\n`
package/src/utils/git.js CHANGED
@@ -167,8 +167,8 @@ export function buildBranchName(prefix, task) {
167
167
  return `${prefix}${slugifyTask(task) || "task"}-${stamp}`;
168
168
  }
169
169
 
170
- export async function hasChanges() {
171
- const status = await runGit(["status", "--porcelain"]);
170
+ export async function hasChanges(cwd = null) {
171
+ const status = await runGit(["status", "--porcelain"], cwd ? { cwd } : {});
172
172
  return status.length > 0;
173
173
  }
174
174
 
@@ -229,12 +229,13 @@ function isNothingToCommit(message) {
229
229
  return NOTHING_TO_COMMIT_PATTERNS.some((re) => re.test(m));
230
230
  }
231
231
 
232
- export async function commitAll(message) {
233
- await runGit(["add", "-A"]);
234
- const changed = await hasChanges();
232
+ export async function commitAll(message, cwd = null) {
233
+ const opts = cwd ? { cwd } : {};
234
+ await runGit(["add", "-A"], opts);
235
+ const changed = await hasChanges(cwd);
235
236
  if (!changed) return { committed: false };
236
237
  try {
237
- await runGit(["commit", "-m", message]);
238
+ await runGit(["commit", "-m", message], opts);
238
239
  } catch (err) {
239
240
  // `git status --porcelain` and `git commit` disagreed about whether
240
241
  // there was anything to commit. Don't escalate — the only outcome
@@ -245,18 +246,18 @@ export async function commitAll(message) {
245
246
  }
246
247
  throw err;
247
248
  }
248
- const raw = await runGit(["log", "-1", "--pretty=format:%H%x1f%s"]);
249
+ const raw = await runGit(["log", "-1", "--pretty=format:%H%x1f%s"], opts);
249
250
  const [hash, commitMessage] = raw.split("\x1f");
250
251
  return { committed: true, commit: { hash, message: commitMessage } };
251
252
  }
252
253
 
253
- export async function pushBranch(branch) {
254
- await runGit(["push", "-u", "origin", branch]);
254
+ export async function pushBranch(branch, cwd = null) {
255
+ await runGit(["push", "-u", "origin", branch], cwd ? { cwd } : {});
255
256
  }
256
257
 
257
- export async function createPullRequest({ baseBranch, branch, title, body }) {
258
+ export async function createPullRequest({ baseBranch, branch, title, body, cwd = null }) {
258
259
  const args = ["pr", "create", "--base", baseBranch, "--head", branch, "--title", title, "--body", body];
259
- const res = await runCommand("gh", args);
260
+ const res = await runCommand("gh", args, cwd ? { cwd } : {});
260
261
  if (res.exitCode !== 0) {
261
262
  throw new Error(`gh ${args.join(" ")} failed: ${res.stderr || res.stdout}`);
262
263
  }