karajan-code 2.17.1 → 2.18.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.
@@ -66,64 +66,191 @@ export function needsSetupHu({ isNewProject = false, stackHints = [], subtasks =
66
66
  return subtasks.some(s => setupKeywords.test(s));
67
67
  }
68
68
 
69
+ // Stack-hint keyword sets. Used by filterConflictingHints (drop hints
70
+ // from other ecosystems once one is detected) and by
71
+ // resolveLanguageFromHints (pick the canonical language for setup +
72
+ // acceptance_tests templates).
73
+ const HINT_NODE = new Set(["express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "nestjs", "monorepo", "workspaces"]);
74
+ const HINT_PYTHON = new Set(["pytest", "flask", "fastapi", "django", "numpy", "pandas"]);
75
+ const HINT_GO = new Set(["gin", "fiber", "go"]);
76
+ const HINT_RUST = new Set(["cargo", "rust"]);
77
+
69
78
  /**
70
- * Filter conflicting stack hints. When Node.js ecosystem keywords are present,
71
- * remove Go/Rust/Python keywords that were detected from gitignore patterns
72
- * but aren't actually part of the task.
79
+ * Drop hints from OTHER ecosystems when one is clearly the project's.
80
+ * Symmetric: previously only node-vs-go was filtered, so a Python
81
+ * project that also had "vitest" in a stale gitignore would still
82
+ * receive vitest in its setup HU.
73
83
  */
74
84
  function filterConflictingHints(hints) {
75
85
  if (!hints || hints.length === 0) return hints;
76
- const nodeEcosystem = new Set(["express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "nestjs", "monorepo", "workspaces"]);
77
- const goKeywords = new Set(["gin", "fiber", "go"]);
78
- const hasNode = hints.some(h => nodeEcosystem.has(h));
79
- if (!hasNode) return hints;
80
- return hints.filter(h => !goKeywords.has(h));
86
+ const setForEachLang = [
87
+ ["python", HINT_PYTHON],
88
+ ["go", HINT_GO],
89
+ ["rust", HINT_RUST],
90
+ ["node", HINT_NODE],
91
+ ];
92
+ const present = setForEachLang.filter(([, set]) => hints.some((h) => set.has(h)));
93
+ if (present.length <= 1) return hints;
94
+ // First ecosystem found in the original hints order wins. We keep
95
+ // ambiguous keywords (those not in any set) and the winner's own
96
+ // keywords; we drop the explicit losers from competing ecosystems.
97
+ const winnerSet = present[0][1];
98
+ const losers = present.slice(1).flatMap(([, s]) => [...s]);
99
+ return hints.filter((h) => winnerSet.has(h) || !losers.includes(h));
100
+ }
101
+
102
+ /**
103
+ * Pick the canonical language for setup + acceptance_test templates
104
+ * based on the (already filtered) stack hints. Falls back to
105
+ * javascript so existing Node-centric runs keep their previous shape.
106
+ * @param {string[]} hints
107
+ * @returns {"python"|"go"|"rust"|"javascript"}
108
+ */
109
+ export function resolveLanguageFromHints(hints) {
110
+ if (!hints || hints.length === 0) return "javascript";
111
+ if (hints.some((h) => HINT_PYTHON.has(h))) return "python";
112
+ if (hints.some((h) => HINT_GO.has(h))) return "go";
113
+ if (hints.some((h) => HINT_RUST.has(h))) return "rust";
114
+ return "javascript";
115
+ }
116
+
117
+ // HU templates per language. The JS template is the legacy default;
118
+ // the others were missing — every autogenerated HU got vitest /
119
+ // npm-install hardcoded acceptance_tests regardless of stack.
120
+ const HU_TEMPLATES = {
121
+ javascript: {
122
+ pkgManager: "npm",
123
+ setupBullets: [
124
+ "- Create package.json (with workspaces if monorepo detected from stack hints)",
125
+ "- Install all runtime + dev dependencies listed in stack hints",
126
+ "- Install test framework WITH coverage reporter (e.g. vitest + @vitest/coverage-v8)",
127
+ "- Configure vitest.config.js with coverage.enabled = true",
128
+ "- Create .env.example with placeholder variables",
129
+ ],
130
+ setupCriteria: [
131
+ "npm install succeeds without errors",
132
+ "npm test runs without error",
133
+ "npm run test:coverage runs without error",
134
+ ".env.example exists",
135
+ ],
136
+ setupTests: [
137
+ "npm install --ignore-scripts 2>&1 && echo PASS || echo FAIL",
138
+ "npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
139
+ "npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL",
140
+ "test -f .env.example && echo PASS || echo FAIL",
141
+ ],
142
+ taskTests: [
143
+ "npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
144
+ "npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL",
145
+ ],
146
+ },
147
+ python: {
148
+ pkgManager: "pip",
149
+ setupBullets: [
150
+ "- Create pyproject.toml (preferred) or requirements.txt",
151
+ "- Create a virtual env: python -m venv .venv",
152
+ "- Install runtime + dev dependencies (pip install -e . or pip install -r requirements.txt)",
153
+ "- Install pytest with coverage (pip install pytest pytest-cov)",
154
+ "- Create .env.example with placeholder variables",
155
+ ],
156
+ setupCriteria: [
157
+ "Project metadata file exists (pyproject.toml or requirements.txt)",
158
+ "pytest runs without error",
159
+ "pytest --cov reports a total",
160
+ ".env.example exists",
161
+ ],
162
+ setupTests: [
163
+ "(test -f pyproject.toml || test -f requirements.txt) && echo PASS || echo FAIL",
164
+ "python -m pytest 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
165
+ "python -m pytest --cov 2>&1 | grep -q 'TOTAL\\|coverage' && echo PASS || echo FAIL",
166
+ "test -f .env.example && echo PASS || echo FAIL",
167
+ ],
168
+ taskTests: [
169
+ "python -m pytest 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
170
+ "python -m pytest --cov 2>&1 | grep -q 'TOTAL\\|coverage' && echo PASS || echo FAIL",
171
+ ],
172
+ },
173
+ go: {
174
+ pkgManager: "go",
175
+ setupBullets: [
176
+ "- Create go.mod with `go mod init <module>`",
177
+ "- Install dependencies with `go get` per import",
178
+ "- Create .env.example with placeholder variables",
179
+ ],
180
+ setupCriteria: [
181
+ "go.mod exists",
182
+ "go test ./... runs without error",
183
+ "go test -cover reports coverage",
184
+ ".env.example exists",
185
+ ],
186
+ setupTests: [
187
+ "test -f go.mod && echo PASS || echo FAIL",
188
+ "go test ./... 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
189
+ "go test -cover ./... 2>&1 | grep -q 'coverage:' && echo PASS || echo FAIL",
190
+ "test -f .env.example && echo PASS || echo FAIL",
191
+ ],
192
+ taskTests: [
193
+ "go test ./... 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
194
+ ],
195
+ },
196
+ rust: {
197
+ pkgManager: "cargo",
198
+ setupBullets: [
199
+ "- Initialise the crate with `cargo init`",
200
+ "- Add dependencies with `cargo add`",
201
+ "- Create .env.example with placeholder variables",
202
+ ],
203
+ setupCriteria: [
204
+ "Cargo.toml exists",
205
+ "cargo test runs without error",
206
+ ".env.example exists",
207
+ ],
208
+ setupTests: [
209
+ "test -f Cargo.toml && echo PASS || echo FAIL",
210
+ "cargo test 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
211
+ "test -f .env.example && echo PASS || echo FAIL",
212
+ ],
213
+ taskTests: [
214
+ "cargo test 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
215
+ ],
216
+ },
217
+ };
218
+
219
+ function templateFor(language) {
220
+ return HU_TEMPLATES[language] || HU_TEMPLATES.javascript;
81
221
  }
82
222
 
83
223
  /**
84
224
  * Build a MINIMAL setup HU — project structure + deps only.
85
225
  * NEVER includes the full original task. The coder must only do setup.
86
226
  */
87
- function buildSetupHu({ stackHints }) {
227
+ function buildSetupHu({ stackHints, language = "javascript" }) {
228
+ const tpl = templateFor(language);
88
229
  const deps = stackHints.length > 0
89
230
  ? stackHints.map(h => `- ${h}`).join("\n")
90
231
  : "- (auto-detect from subsequent HUs)";
91
232
  const certifiedText = [
92
- "**Setup: initialize project structure and install dependencies.**",
233
+ `**Setup: initialise the ${language} project + install dependencies (package manager: ${tpl.pkgManager}).**`,
93
234
  "",
94
235
  "SCOPE (do ONLY this, nothing else):",
95
- "- Create package.json (with workspaces if monorepo detected from stack hints)",
96
- "- Install all runtime + dev dependencies listed in stack hints",
97
- "- Install test framework WITH coverage reporter (e.g. vitest + @vitest/coverage-v8)",
98
- "- Configure vitest.config.js with coverage.enabled = true",
99
- "- Create .env.example with placeholder variables",
236
+ ...tpl.setupBullets,
100
237
  "- Verify by running each acceptance_test command below",
101
238
  "",
102
239
  "DO NOT implement any business logic, API routes, components, or features.",
103
240
  "This HU is ONLY project scaffolding.",
104
241
  "",
105
242
  "Stack hints:",
106
- deps
243
+ deps,
107
244
  ].join("\n");
108
245
  return {
109
246
  id: "HU-01",
110
- title: "Setup: project structure + dependencies",
247
+ title: `Setup: ${language} project structure + dependencies`,
111
248
  task_type: "infra",
112
249
  status: "certified",
113
250
  blocked_by: [],
114
251
  certified: { text: certifiedText },
115
- acceptance_criteria: [
116
- "npm install succeeds without errors",
117
- "npm test runs without error",
118
- "npm run test:coverage runs without error",
119
- ".env.example exists"
120
- ],
121
- acceptance_tests: [
122
- "npm install --ignore-scripts 2>&1 && echo PASS || echo FAIL",
123
- "npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
124
- "npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL",
125
- "test -f .env.example && echo PASS || echo FAIL"
126
- ]
252
+ acceptance_criteria: tpl.setupCriteria,
253
+ acceptance_tests: tpl.setupTests,
127
254
  };
128
255
  }
129
256
 
@@ -131,8 +258,9 @@ function buildSetupHu({ stackHints }) {
131
258
  * Build a MINIMAL task HU — one specific, focused piece of work.
132
259
  * Includes a short goal reference (max 80 chars) NOT the full task.
133
260
  */
134
- function buildTaskHu({ id, subtask, projectName, blockedBy }) {
261
+ function buildTaskHu({ id, subtask, projectName, blockedBy, language = "javascript" }) {
135
262
  const taskType = classifyTaskType(subtask);
263
+ const tpl = templateFor(language);
136
264
  const certifiedText = [
137
265
  `**${subtask}**`,
138
266
  "",
@@ -143,7 +271,7 @@ function buildTaskHu({ id, subtask, projectName, blockedBy }) {
143
271
  "- Add unit tests for the new code",
144
272
  "- Run ALL acceptance_tests listed below and ensure they pass",
145
273
  "- Do NOT touch code outside this subtask's scope",
146
- "- Target: <200 lines changed (like an atomic PR)"
274
+ "- Target: <200 lines changed (like an atomic PR)",
147
275
  ].join("\n");
148
276
  return {
149
277
  id,
@@ -155,12 +283,9 @@ function buildTaskHu({ id, subtask, projectName, blockedBy }) {
155
283
  acceptance_criteria: [
156
284
  `${subtask} is implemented and working`,
157
285
  "Unit tests cover the new code",
158
- "All acceptance_tests pass"
286
+ "All acceptance_tests pass",
159
287
  ],
160
- acceptance_tests: [
161
- "npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
162
- "npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL"
163
- ]
288
+ acceptance_tests: tpl.taskTests,
164
289
  };
165
290
  }
166
291
 
@@ -182,7 +307,8 @@ export function generateHuBatch({
182
307
  stackHints = [],
183
308
  isNewProject = false,
184
309
  researcherContext = null,
185
- architectContext = null
310
+ architectContext = null,
311
+ language = null,
186
312
  }) {
187
313
  if (!originalTask || typeof originalTask !== "string") {
188
314
  throw new Error("generateHuBatch: originalTask is required");
@@ -194,12 +320,17 @@ export function generateHuBatch({
194
320
  const stories = [];
195
321
  const filteredHints = filterConflictingHints(stackHints);
196
322
  const needsSetup = needsSetupHu({ isNewProject, stackHints: filteredHints, subtasks });
323
+ // Caller may pass a language explicitly (when it has a real
324
+ // detectProjectStack result from the filesystem); otherwise fall
325
+ // back to inferring from the hint keywords. Default 'javascript'
326
+ // matches the previous behaviour for Node-centric runs.
327
+ const resolvedLanguage = language || resolveLanguageFromHints(filteredHints);
197
328
  let nextId = 1;
198
329
 
199
330
  const projectName = deriveProjectName(originalTask);
200
331
 
201
332
  if (needsSetup) {
202
- stories.push(buildSetupHu({ stackHints: filteredHints }));
333
+ stories.push(buildSetupHu({ stackHints: filteredHints, language: resolvedLanguage }));
203
334
  nextId = 2;
204
335
  }
205
336
 
@@ -211,7 +342,7 @@ export function generateHuBatch({
211
342
  const blockedBy = [];
212
343
  if (setupId) blockedBy.push(setupId);
213
344
  if (previousId && previousId !== setupId) blockedBy.push(previousId);
214
- stories.push(buildTaskHu({ id, subtask, projectName, blockedBy }));
345
+ stories.push(buildTaskHu({ id, subtask, projectName, blockedBy, language: resolvedLanguage }));
215
346
  previousId = id;
216
347
  nextId += 1;
217
348
  }
@@ -18,6 +18,25 @@ import { stageRegistry } from "../../stages/stage-classes.js";
18
18
  import { runStage } from "../../stages/stage-executor.js";
19
19
  import { handleStandbyResult } from "../error-recovery.js";
20
20
 
21
+ /**
22
+ * A coder/refactorer stage hit a provider quota cap and Brain Recovery
23
+ * hibernated the run. Turn it into a clean loop return carrying
24
+ * `hibernated:true` — the iteration loop and flow-runner read that to
25
+ * seal the session as `hibernated` (NOT `failed`) so it stays resumable.
26
+ */
27
+ function hibernateReturn(stageResult, session) {
28
+ return {
29
+ action: "return",
30
+ result: {
31
+ hibernated: true,
32
+ sessionId: session?.id || null,
33
+ reason: "quota_exhausted",
34
+ standbyFile: stageResult.standbyFile || null,
35
+ recovery: stageResult.recovery || null,
36
+ },
37
+ };
38
+ }
39
+
21
40
  export async function runCoderAndRefactorerStages({ coderRoleInstance, coderRole, refactorerRole, pipelineFlags, config, logger, emitter, eventBase, session, plannedTask, trackBudget, i, brainCtx }) {
22
41
  // Coder via StageRegistry (TSK-0336). canRun = coderRequired !== false; in
23
42
  // analysis-only task types coderRequired is set to false by policy, so the
@@ -25,6 +44,7 @@ export async function runCoderAndRefactorerStages({ coderRoleInstance, coderRole
25
44
  // runFlow.
26
45
  const coderCtx = { coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i, brainCtx, pipelineFlags };
27
46
  const coderResult = await runStage(stageRegistry.get("coder"), coderCtx);
47
+ if (coderResult?.action === "hibernate") return hibernateReturn(coderResult, session);
28
48
  if (coderResult?.action === "pause") return { action: "return", result: coderResult.result };
29
49
  const coderStandby = await handleStandbyResult({ stageResult: coderResult, session, emitter, eventBase, i, stage: "coder", logger, config });
30
50
  if (coderStandby.handled) {
@@ -35,6 +55,7 @@ export async function runCoderAndRefactorerStages({ coderRoleInstance, coderRole
35
55
 
36
56
  if (pipelineFlags.refactorerEnabled) {
37
57
  const refResult = await runRefactorerStage({ refactorerRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
58
+ if (refResult?.action === "hibernate") return hibernateReturn(refResult, session);
38
59
  if (refResult?.action === "pause") return { action: "return", result: refResult.result };
39
60
  const refStandby = await handleStandbyResult({ stageResult: refResult, session, emitter, eventBase, i, stage: "refactorer", logger, config });
40
61
  if (refStandby.handled) {
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { emitProgress, makeEvent } from "#utils/events.js";
19
+ import { detectProjectStack } from "#utils/stack-detect.js";
19
20
 
20
21
  export async function maybeGenerateAutoHuBatch({
21
22
  flags, stageResults, task, logger, emitter, eventBase, projectDir, session,
@@ -40,19 +41,38 @@ export async function maybeGenerateAutoHuBatch({
40
41
  isNewProject = relevant.length === 0;
41
42
  } catch { /* ignore */ }
42
43
 
43
- // Extract stack hints from planner + architect output
44
+ // Stack hints from planner + architect text. Now multi-ecosystem
45
+ // (was 13/16 JS keywords — Python/Go/Rust barely existed). Combined
46
+ // with `detectProjectStack(projectDir)` below to override the text
47
+ // heuristic with the actual filesystem reality when available.
44
48
  const stackHints = [];
45
49
  const combined = `${stageResults.planner?.plan || ""} ${stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : ""} ${task}`.toLowerCase();
46
- const stackKeywords = ["express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "fastapi", "django", "spring", "gin", "nestjs", "monorepo", "workspaces"];
50
+ const stackKeywords = [
51
+ "express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "nestjs", "monorepo", "workspaces",
52
+ "pytest", "flask", "fastapi", "django", "numpy", "pandas",
53
+ "gin", "fiber", "go",
54
+ "cargo", "rust",
55
+ "spring",
56
+ ];
47
57
  for (const kw of stackKeywords) {
48
58
  if (combined.includes(kw)) stackHints.push(kw);
49
59
  }
50
60
 
61
+ // Filesystem trumps text. detectProjectStack inspects package.json /
62
+ // pyproject.toml / go.mod / Cargo.toml, so a pure-python repo gets
63
+ // `language: "python"` regardless of what the task text says.
64
+ let detectedLanguage = null;
65
+ try {
66
+ const fsStack = await detectProjectStack(projectDir);
67
+ if (fsStack?.language) detectedLanguage = fsStack.language;
68
+ } catch { /* best-effort */ }
69
+
51
70
  const batch = generateHuBatch({
52
71
  originalTask: task,
53
72
  subtasks,
54
73
  stackHints,
55
74
  isNewProject,
75
+ language: detectedLanguage,
56
76
  researcherContext: stageResults.researcher?.summary || null,
57
77
  architectContext: stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : null
58
78
  });
@@ -32,6 +32,7 @@ import {
32
32
  setLiveOutcomeUpdater, setPreLoopContext,
33
33
  } from "#session/mutators.js";
34
34
  import { emitProgress, makeEvent } from "#utils/events.js";
35
+ import { listActiveRuns } from "../../../utils/run-registry.js";
35
36
 
36
37
  /**
37
38
  * @param {object} args
@@ -64,6 +65,19 @@ export async function injectLoadedPlan({ flags, updatedConfig, session, stageRes
64
65
  message: `Plan loaded: ${flags.plan}`,
65
66
  detail: { planId: flags.plan, task: loadedPlan.task, version: loadedPlan.version, huCount: loadedPlan.hus?.length || 0 }
66
67
  }));
68
+
69
+ // Zombie reconciliation: a previous run may have died mid-flight,
70
+ // leaving HUs stuck in coding/reviewing/running. The board-side
71
+ // reaper only runs inside the board; without this, a headless
72
+ // `kj run --plan` would refuse to pick those HUs up.
73
+ const reaped = await reconcilePlanHuZombies({ loadedPlan, planId: flags.plan, logger });
74
+ if (reaped > 0) {
75
+ await savePlanToDisk(projectDir, loadedPlan);
76
+ emitProgress(emitter, makeEvent("plan:zombies-reaped", { ...eventBase, stage: "plan" }, {
77
+ message: `Reset ${reaped} stale HU(s) from a previous run that did not finish`,
78
+ detail: { planId: flags.plan, count: reaped }
79
+ }));
80
+ }
67
81
  stageResults.researcher = { ok: true, summary: "Loaded from persisted plan", fromPlan: flags.plan };
68
82
  stageResults.architect = { ok: true, summary: "Loaded from persisted plan", fromPlan: flags.plan };
69
83
  stageResults.planner = { ok: true, summary: "Loaded from persisted plan", fromPlan: flags.plan };
@@ -122,6 +136,48 @@ export async function injectLoadedPlan({ flags, updatedConfig, session, stageRes
122
136
  }
123
137
  }
124
138
 
139
+ /**
140
+ * Reset HUs that look mid-execution but have no live run behind them.
141
+ *
142
+ * Resilience audit, Phase 3 (Hallazgo A1 / status-zombi). A previous
143
+ * `kj run --plan` flipped HUs to `coding`/`reviewing`/`running` in the
144
+ * plan JSON, then died (SIGKILL, terminal closed, OOM, crash). The
145
+ * board-side `hu-zombie-reaper` only queries SQLite and only runs
146
+ * inside the board process, so the plan JSON on disk kept stale
147
+ * in-flight statuses forever. The next `kj run --plan` then refused to
148
+ * pick those HUs up.
149
+ *
150
+ * Cross-check via `run-registry`: if there is a live run with our
151
+ * planId, do NOT touch — that run owns the in-flight HUs. Otherwise
152
+ * any in-flight status with no live owner is a zombie → reset to
153
+ * `pending` so it can run again.
154
+ *
155
+ * Mutates `loadedPlan.hus` in place. Returns the number of HUs reset
156
+ * so the caller can persist + emit a progress event.
157
+ *
158
+ * @param {object} args
159
+ * @param {object} args.loadedPlan
160
+ * @param {string} args.planId
161
+ * @param {object} [args.logger]
162
+ * @returns {number}
163
+ */
164
+ export function reconcilePlanHuZombies({ loadedPlan, planId, logger }) {
165
+ if (!Array.isArray(loadedPlan?.hus) || loadedPlan.hus.length === 0) return 0;
166
+ const activeRuns = listActiveRuns({ planId });
167
+ if (activeRuns.length > 0) return 0;
168
+ const ZOMBIE_STATES = new Set(["coding", "reviewing", "running"]);
169
+ let reaped = 0;
170
+ for (const hu of loadedPlan.hus) {
171
+ if (ZOMBIE_STATES.has(hu.status)) {
172
+ const previous = hu.status;
173
+ hu.status = "pending";
174
+ reaped += 1;
175
+ logger?.warn?.(`[plan] HU ${hu.id || hu.hu_id || "?"} was '${previous}' but no live run owns the plan — reset to 'pending'.`);
176
+ }
177
+ }
178
+ return reaped;
179
+ }
180
+
125
181
  /**
126
182
  * Throws when any HU lacks an acceptance_tests array with at least
127
183
  * one entry. Logs + emits the same `plan:tests-missing` progress
@@ -74,6 +74,7 @@ import { runSecurityStage, runFinalAuditStage } from "./post-loop-stages.js";
74
74
  * approved → "approved"
75
75
  * paused → "paused"
76
76
  * cancelled → "cancelled"
77
+ * hibernated → "hibernated"
77
78
  * anything else → "failed"
78
79
  *
79
80
  * Idempotent: only writes when the current status is still "running"
@@ -95,6 +96,10 @@ export async function sealSessionStatusIfStillRunning(session, result) {
95
96
  if (result?.approved === true) target = "approved";
96
97
  else if (result?.paused === true) target = "paused";
97
98
  else if (result?.cancelled === true) target = "cancelled";
99
+ // A run hibernated on a provider quota cap is NOT a failure — it is
100
+ // suspended and resumable via `kj standby resume`. Sealing it as
101
+ // `failed` would let the HU-zombie reaper treat it as dead work.
102
+ else if (result?.hibernated === true) target = "hibernated";
98
103
  await markSessionStatus(session, target);
99
104
  } catch { /* non-blocking */ }
100
105
  }
@@ -18,6 +18,7 @@ import { runCoderWithFallback } from "../agent-fallback.js";
18
18
  import { invokeSolomon } from "../solomon-escalation.js";
19
19
  import { detectRateLimit } from "../../utils/rate-limit-detector.js";
20
20
  import { createStallDetector } from "../../utils/stall-detector.js";
21
+ import { buildStandbyState } from "../../brain/standby-store.js";
21
22
  import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage, verifyLeaksAgainstTranscript, detectTranscriptCdLeaks } from "../fs-leak-detector.js";
22
23
 
23
24
  export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration, brainCtx, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null }) {
@@ -66,7 +67,10 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
66
67
  // the plan, the rest are scoped to this HU. All four pass through
67
68
  // untouched when the caller omits them (legacy single-task runs).
68
69
  adrs, specSection, reviewerFindings, huId,
69
- onOutput: coderStall.onOutput
70
+ onOutput: coderStall.onOutput,
71
+ // Lets Brain Recovery persist a standby snapshot if the coder's
72
+ // provider hits a quota cap mid-run (KJC hibernation wiring).
73
+ sessionState: buildStandbyState({ session, config, huId }),
70
74
  });
71
75
  } finally {
72
76
  coderStall.stop();
@@ -75,6 +79,21 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
75
79
 
76
80
  if (!coderExecResult.ok) {
77
81
  const details = coderExecResult.result?.error || coderExecResult.summary || "unknown error";
82
+ // Quota cap → Brain Recovery already hibernated the run and persisted
83
+ // the standby snapshot. Stop the stage cleanly: no fallback, no
84
+ // Solomon. The caller turns this into a clean run halt; the HU is NOT
85
+ // marked failed — it resumes via `kj standby resume`.
86
+ if (coderExecResult.result?.action === "hibernate") {
87
+ emitProgress(emitter, makeEvent("coder:hibernate", { ...eventBase, stage: "coder" }, {
88
+ message: "Coder hibernated — provider quota exhausted, run can be resumed",
89
+ detail: { standbyFile: coderExecResult.result?.standbyFile || null }
90
+ }));
91
+ return {
92
+ action: "hibernate",
93
+ standbyFile: coderExecResult.result?.standbyFile || null,
94
+ recovery: coderExecResult.result?.recovery || null,
95
+ };
96
+ }
78
97
  const rateLimitCheck = detectRateLimit({
79
98
  stderr: coderExecResult.result?.error || "",
80
99
  stdout: coderExecResult.result?.output || ""
@@ -190,8 +209,17 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
190
209
  baseRef: session.session_start_sha,
191
210
  projectDir: config.projectDir || process.cwd()
192
211
  });
193
- filesChanged = verif.filesChanged || 0;
194
- } catch { /* ignore verification errors */ }
212
+ if (verif.gitError) {
213
+ // Git failed (bad baseRef, corrupt repo, git missing). Don't
214
+ // feed filesChanged=0 into stale detection — it would blame the
215
+ // coder for an infrastructure problem.
216
+ logger?.warn?.(`[verification-gate] Git failed, change count is unreliable: ${verif.gitError}`);
217
+ } else {
218
+ filesChanged = verif.filesChanged || 0;
219
+ }
220
+ } catch (err) {
221
+ logger?.warn?.(`[verification-gate] Skipped: ${err?.message || err}`);
222
+ }
195
223
 
196
224
  await addCheckpoint(session, { stage: "coder", iteration, note: "Coder applied changes", provider: coderRole.provider, model: coderRole.model || null, filesChanged });
197
225
  emitProgress(
@@ -245,13 +273,28 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
245
273
  const refactorerStart = Date.now();
246
274
  let refResult;
247
275
  try {
248
- refResult = await refRole.execute({ task: plannedTask, onOutput: refactorerStall.onOutput });
276
+ refResult = await refRole.execute({
277
+ task: plannedTask, onOutput: refactorerStall.onOutput,
278
+ sessionState: buildStandbyState({ session, config }),
279
+ });
249
280
  } finally {
250
281
  refactorerStall.stop();
251
282
  }
252
283
  trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
253
284
  if (!refResult.ok) {
254
285
  const details = refResult.result?.error || refResult.summary || "unknown error";
286
+ // Quota cap → hibernate cleanly, same as the coder stage above.
287
+ if (refResult.result?.action === "hibernate") {
288
+ emitProgress(emitter, makeEvent("refactorer:hibernate", { ...eventBase, stage: "refactorer" }, {
289
+ message: "Refactorer hibernated — provider quota exhausted, run can be resumed",
290
+ detail: { standbyFile: refResult.result?.standbyFile || null }
291
+ }));
292
+ return {
293
+ action: "hibernate",
294
+ standbyFile: refResult.result?.standbyFile || null,
295
+ recovery: refResult.result?.recovery || null,
296
+ };
297
+ }
255
298
  const rateLimitCheck = detectRateLimit({
256
299
  stderr: refResult.result?.error || "",
257
300
  stdout: refResult.result?.output || ""
@@ -47,8 +47,16 @@ export function countChangesSince(baseRef, projectDir = null) {
47
47
  }
48
48
 
49
49
  return { files, filesChanged: files.length, linesAdded, linesDeleted };
50
- } catch {
51
- return { files: [], filesChanged: 0, linesAdded: 0, linesDeleted: 0 };
50
+ } catch (err) {
51
+ // Git itself failed (baseRef invalid, repo corrupt, git binary
52
+ // missing). Previously we returned `filesChanged: 0` silently —
53
+ // indistinguishable from "the coder did nothing". Surface the
54
+ // error so verifyCoderOutput stops blaming the agent for an
55
+ // infrastructure failure.
56
+ return {
57
+ files: [], filesChanged: 0, linesAdded: 0, linesDeleted: 0,
58
+ gitError: err?.stderr?.toString?.() || err?.message || String(err),
59
+ };
52
60
  }
53
61
  }
54
62
 
@@ -65,10 +73,13 @@ export function countUntrackedFiles(projectDir = null) {
65
73
  encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
66
74
  }).trim();
67
75
 
68
- if (!output) return [];
69
- return output.split("\n").filter(Boolean);
70
- } catch {
71
- return [];
76
+ if (!output) return { files: [] };
77
+ return { files: output.split("\n").filter(Boolean) };
78
+ } catch (err) {
79
+ return {
80
+ files: [],
81
+ gitError: err?.stderr?.toString?.() || err?.message || String(err),
82
+ };
72
83
  }
73
84
  }
74
85
 
@@ -87,11 +98,29 @@ export function verifyCoderOutput({ baseRef, projectDir = null, minFiles = 1, mi
87
98
  const tracked = countChangesSince(baseRef, projectDir);
88
99
  const untracked = countUntrackedFiles(projectDir);
89
100
 
90
- const totalFiles = tracked.filesChanged + untracked.length;
101
+ // Git infra failure (bad baseRef, corrupt repo, git missing) — not
102
+ // the coder's fault. Bail out with `gitError` so the caller can log
103
+ // it and skip the "retry with explicit file paths" feedback that
104
+ // would otherwise blame the agent.
105
+ const gitError = tracked.gitError || untracked.gitError;
106
+ if (gitError) {
107
+ return {
108
+ passed: false,
109
+ filesChanged: 0,
110
+ linesChanged: 0,
111
+ files: [],
112
+ reason: `Git verification failed: ${gitError}`,
113
+ retryStrategy: null,
114
+ gitError,
115
+ };
116
+ }
117
+
118
+ const untrackedFiles = untracked.files || [];
119
+ const totalFiles = tracked.filesChanged + untrackedFiles.length;
91
120
  const totalLines = tracked.linesAdded + tracked.linesDeleted;
92
- const allFiles = [...tracked.files, ...untracked];
121
+ const allFiles = [...tracked.files, ...untrackedFiles];
93
122
 
94
- const passed = totalFiles >= minFiles && (untracked.length > 0 || totalLines >= minLines);
123
+ const passed = totalFiles >= minFiles && (untrackedFiles.length > 0 || totalLines >= minLines);
95
124
 
96
125
  if (passed) {
97
126
  return {