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 +1 -1
- package/src/cli/register-pipeline.js +2 -0
- package/src/commands/init.js +9 -0
- package/src/config/defaults.js +6 -0
- package/src/config/overrides.js +4 -0
- package/src/git/hu-automation.js +6 -5
- package/src/orchestrator/drivers/iteration-loop.js +30 -0
- package/src/orchestrator/drivers/iteration-phases/guards.js +1 -1
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +6 -3
- package/src/orchestrator/drivers/run-hu-batch.js +75 -51
- package/src/orchestrator/hu-scheduler.js +114 -0
- package/src/orchestrator/hu-sub-pipeline.js +45 -11
- package/src/orchestrator/iteration-gate.js +73 -0
- package/src/orchestrator/parallel-limiter.js +74 -0
- package/src/orchestrator/stages/coder-stage.js +7 -2
- package/src/orchestrator/stages/reviewer-stage.js +3 -3
- package/src/plan/plan-executor.js +4 -0
- package/src/plan/preflight-hu.js +3 -0
- package/src/review/diff-generator.js +2 -2
- package/src/roles/coder-role.js +6 -2
- package/src/spec-review/run-spec-review.js +5 -1
- package/src/utils/async-lock.js +31 -0
- package/src/utils/cli-ask-question.js +9 -0
- package/src/utils/git.js +12 -11
package/package.json
CHANGED
|
@@ -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)")
|
package/src/commands/init.js
CHANGED
|
@@ -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:");
|
package/src/config/defaults.js
CHANGED
|
@@ -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,
|
package/src/config/overrides.js
CHANGED
|
@@ -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;
|
package/src/git/hu-automation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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 <=
|
|
146
|
-
logger.info(`HU ${story.id}: coder iteration ${attempt}/${
|
|
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}/${
|
|
149
|
-
detail: { iteration: attempt, maxIterations:
|
|
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:
|
|
159
|
-
session:
|
|
160
|
-
trackBudget: ctx.trackBudget, iteration: attempt, 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 &&
|
|
202
|
+
if (huPolicies.sonar && laneConfig.sonarqube?.enabled) {
|
|
177
203
|
try {
|
|
178
204
|
const sonarResult = await runSonarStage({
|
|
179
|
-
config:
|
|
180
|
-
session:
|
|
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:
|
|
217
|
+
askQuestion, brainCtx: laneBrain
|
|
192
218
|
});
|
|
193
219
|
if (sonarResult?.action === "continue") {
|
|
194
220
|
// Sonar failed — add to feedback for next coder attempt
|
|
195
|
-
|
|
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:
|
|
229
|
-
return { approved: true, sessionId:
|
|
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:
|
|
240
|
-
session:
|
|
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:
|
|
248
|
-
return { approved: true, sessionId:
|
|
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(
|
|
263
|
-
|
|
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:
|
|
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(
|
|
332
|
-
|
|
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:
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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 || [],
|
package/src/plan/preflight-hu.js
CHANGED
|
@@ -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
|
}
|
package/src/roles/coder-role.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
234
|
-
|
|
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
|
}
|