karajan-code 3.13.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.13.0",
3
+ "version": "3.14.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",
@@ -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:");
@@ -55,7 +55,10 @@ const DEFAULTS = {
55
55
  review_mode: "standard",
56
56
  max_iterations: 5,
57
57
  hu_max_iterations: 3,
58
- max_budget_usd: null,
58
+ // Hard per-run spend ceiling, ON by default (KJC-TSK-0621): a stuck or
59
+ // runaway run must never drain a subscription quota unattended. Explicit
60
+ // null in the user's config opts out (no cap).
61
+ max_budget_usd: 5,
59
62
  review_rules: "./.karajan/review-rules.md",
60
63
  coder_rules: "./.karajan/coder-rules.md",
61
64
  base_branch: "main",
@@ -173,6 +176,12 @@ const DEFAULTS = {
173
176
  role_overrides: {}
174
177
  },
175
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,
176
185
  max_iteration_minutes: 30,
177
186
  max_total_minutes: 120,
178
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;
@@ -172,8 +176,14 @@ export function applyRunOverrides(config, flags) {
172
176
  out.git = out.git || {};
173
177
  out.development = out.development || {};
174
178
  out.sonarqube = out.sonarqube || {};
175
- if (out.max_budget_usd === undefined || out.max_budget_usd === null) {
176
- out.max_budget_usd = out.session.max_budget_usd ?? null;
179
+ // Precedence: explicit top-level value (including null = opt-out) wins;
180
+ // the legacy session.max_budget_usd location next; the shipped default
181
+ // (KJC-TSK-0621: 5) last. When top-level still carries the default, a
182
+ // session-level value — including an explicit null — takes over.
183
+ if (out.max_budget_usd === undefined) {
184
+ out.max_budget_usd = out.session.max_budget_usd !== undefined ? out.session.max_budget_usd : DEFAULTS.max_budget_usd;
185
+ } else if (out.max_budget_usd === DEFAULTS.max_budget_usd && out.session.max_budget_usd !== undefined) {
186
+ out.max_budget_usd = out.session.max_budget_usd;
177
187
  }
178
188
  out.budget = mergeDeep(DEFAULTS.budget, out.budget || {});
179
189
  out.roles = mergeDeep(DEFAULTS.roles, out.roles || {});
@@ -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
@@ -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) {
@@ -153,7 +153,11 @@ export async function checkBudgetExceeded({ budgetTracker, config, session, emit
153
153
 
154
154
  await markSessionStatus(session, "failed");
155
155
  const totalCost = budgetTracker.total().cost_usd;
156
- const message = `Budget exceeded: $${totalCost.toFixed(2)} > $${budgetLimit.toFixed(2)}`;
156
+ const limit = Number(budgetLimit ?? config?.max_budget_usd);
157
+ const message =
158
+ `Budget exceeded: $${totalCost.toFixed(2)} > $${limit.toFixed(2)}. ` +
159
+ `Continue consciously with \`kj resume ${session.id}\`, or raise the cap ` +
160
+ `(max_budget_usd in .karajan/kj.config.yml; null removes it).`;
157
161
  emitProgress(
158
162
  emitter,
159
163
  makeEvent("session:end", { ...eventBase, iteration: i, stage: "budget" }, {
@@ -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.
@@ -459,15 +461,38 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
459
461
  // --- Group HUs into parallel batches ---
460
462
  const parallelBatches = findParallelGroups(certifiedStories, orderedIds);
461
463
 
462
- for (const group of parallelBatches) {
464
+ // Governance (KJC-TSK-0626): the dependency groups above used to launch
465
+ // UNBOUNDED via Promise.all — a plan with N independent HUs ran N full
466
+ // pipelines at once, with no cap, no shared budget and no scope checks.
467
+ // Now each group is partitioned into conflict-free chunks of at most
468
+ // max_parallel_hus (default 1 = fully sequential, today's safe path) and
469
+ // a shared limiter gates every launch against the PLAN budget.
470
+ const maxParallel = Math.max(1, Number(config?.session?.max_parallel_hus ?? 1));
471
+ const limiter = createParallelLimiter({
472
+ maxParallel,
473
+ budgetTracker,
474
+ planBudgetUsd: planBudgetUsd({ maxBudgetUsd: config?.max_budget_usd, maxParallel }),
475
+ });
476
+ let stopReason = null;
477
+
478
+ outer: for (const group of parallelBatches) {
479
+ for (const chunk of partitionConflictFree(batch.stories, group, maxParallel)) {
463
480
  // Filter out HUs that were blocked by a failed dependency in a previous batch
464
- const runnableIds = group.filter(id => {
481
+ const runnableIds = chunk.filter(id => {
465
482
  const story = batch.stories.find(s => s.id === id);
466
483
  return story && story.status !== HU_STATUS.BLOCKED;
467
484
  });
468
485
 
469
486
  if (runnableIds.length === 0) continue;
470
487
 
488
+ const blockReason = limiter.launchBlockReason();
489
+ if (blockReason) {
490
+ stopReason = blockReason;
491
+ allApproved = false;
492
+ logger.warn(`HU sub-pipeline stopped before launching ${runnableIds.join(", ")}: ${blockReason}`);
493
+ break outer;
494
+ }
495
+
471
496
  // Emit parallel batch start event
472
497
  emitProgress(emitter, makeEvent("hu:parallel-start", { ...eventBase, stage: "hu-sub-pipeline" }, {
473
498
  message: `Starting parallel batch of ${runnableIds.length} HU(s): ${runnableIds.join(", ")}`,
@@ -511,14 +536,20 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
511
536
  }
512
537
  }
513
538
 
514
- // Run all HUs in the batch concurrently
539
+ // Run all HUs in the batch concurrently, each holding a limiter slot
540
+ // (belt-and-braces: chunks are already ≤ maxParallel).
515
541
  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
- });
542
+ await limiter.acquire();
543
+ try {
544
+ return await runSingleHu({
545
+ storyId, batch, batchSessionId, runIterationFn,
546
+ emitter, eventBase, logger, config, results,
547
+ worktreePath: worktrees.get(storyId),
548
+ onStatusChange, onOutcome, budgetTracker
549
+ });
550
+ } finally {
551
+ limiter.release();
552
+ }
522
553
  });
523
554
 
524
555
  const batchResults = await Promise.all(batchPromises);
@@ -554,7 +585,8 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
554
585
 
555
586
  await saveHuBatch(batchSessionId, batch);
556
587
  }
588
+ }
557
589
  }
558
590
 
559
- return { approved: allApproved, results, blockedIds };
591
+ return { approved: allApproved, results, blockedIds, ...(stopReason ? { stopReason } : {}) };
560
592
  }
@@ -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
+ }
@@ -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
+ }
@@ -114,5 +114,7 @@ output:
114
114
  session:
115
115
  max_iteration_minutes: 15
116
116
  max_total_minutes: 120
117
- max_budget_usd: null
117
+ # Hard per-run spend ceiling (USD-equivalent). The run stops with a summary
118
+ # when exceeded; resume with `kj resume`. Set to null to remove the cap.
119
+ max_budget_usd: 5
118
120
  fail_fast_repeats: 2