karajan-code 2.14.3 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/brain/agent-error-classifier.js +101 -0
  4. package/src/brain/standby-scheduler.js +92 -0
  5. package/src/brain/standby-store.js +129 -0
  6. package/src/brain/with-brain-recovery.js +178 -0
  7. package/src/cli/register-plan.js +29 -0
  8. package/src/cli/register-standby.js +26 -0
  9. package/src/cli.js +2 -0
  10. package/src/commands/architect.js +6 -2
  11. package/src/commands/discover.js +6 -2
  12. package/src/commands/init.js +47 -0
  13. package/src/commands/plan/_shared.js +19 -4
  14. package/src/commands/plan/fix.js +99 -0
  15. package/src/commands/plan/generate.js +112 -27
  16. package/src/commands/plan/index.js +2 -0
  17. package/src/commands/plan/migrate-result.js +41 -0
  18. package/src/commands/plan.js +2 -0
  19. package/src/commands/researcher.js +6 -2
  20. package/src/commands/run.js +33 -1
  21. package/src/commands/standby.js +114 -0
  22. package/src/commands/triage.js +6 -2
  23. package/src/config/schema.js +40 -0
  24. package/src/git/hu-snapshot.js +105 -0
  25. package/src/guards/policy-resolver.js +38 -1
  26. package/src/hu/lazy-planner.js +6 -1
  27. package/src/hu/model-router.js +108 -0
  28. package/src/hu/splitting-generator.js +6 -1
  29. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
  30. package/src/orchestrator/drivers/run-hu-batch.js +30 -4
  31. package/src/plan/plan-executor.js +13 -4
  32. package/src/plan/plan-fixer.js +36 -4
  33. package/src/plan/plan-hu-ops.js +24 -1
  34. package/src/plan/plan-id.js +20 -0
  35. package/src/plan/plan-migrate-result.js +24 -0
  36. package/src/plan/plan-reviewer.js +40 -3
  37. package/src/plan/plan-schema.js +128 -1
  38. package/src/plan/plan-store.js +63 -8
  39. package/src/plan/plan-structural-pass.js +122 -0
  40. package/src/plan/plan-validation.js +82 -0
  41. package/src/plan/tests-synthesizer.js +8 -3
  42. package/src/prompts/planner.js +27 -1
  43. package/src/roles/agent-role.js +18 -3
  44. package/src/roles/planner-role.js +10 -3
  45. package/src/roles/solomon-role.js +10 -4
  46. package/src/utils/cli-progress.js +58 -1
  47. package/src/utils/garbage-collector.js +85 -2
  48. package/src/utils/run-registry.js +115 -0
@@ -127,6 +127,52 @@ async function askPerRoleProviders(wizard, available, config, { coder, reviewer
127
127
  }
128
128
  }
129
129
 
130
+ /**
131
+ * KJC-TSK-0415: Plan B — fallback chain. Cuando el provider primario se
132
+ * queda sin créditos (QUOTA_EXHAUSTED_DAILY/MONTHLY) y el cooldown excede
133
+ * max_wait_hours, Brain switchea al fallback en vez de hibernar.
134
+ *
135
+ * Solo pregunta si hay >= 2 providers disponibles (sin alternativas reales
136
+ * no tiene sentido el fallback).
137
+ */
138
+ async function askFallbackChain(wizard, available, config, { coder, reviewer }, logger) {
139
+ if (available.length < 2) return;
140
+
141
+ logger.info("");
142
+ logger.info("Plan B — fallback automático cuando un provider se queda sin créditos:");
143
+ const enable = await wizard.confirm(
144
+ " ¿Configurar fallback? (default: 12h; si el cooldown supera ese tiempo, switch al provider alternativo)",
145
+ true
146
+ );
147
+ if (!enable) return;
148
+
149
+ const maxHoursRaw = await wizard.input(" Máximo tiempo de espera antes de fallback (horas):", "12");
150
+ const maxHours = Number.parseFloat(maxHoursRaw) || 12;
151
+
152
+ config.roles = config.roles || {};
153
+ const KEY_ROLES = [
154
+ { key: "coder", label: "coder", primary: coder },
155
+ { key: "reviewer", label: "reviewer", primary: reviewer },
156
+ { key: "planner", label: "planner", primary: coder },
157
+ ];
158
+ for (const role of KEY_ROLES) {
159
+ config.roles[role.key] = config.roles[role.key] || {};
160
+ const opts = [{ label: "Sin fallback (hibernar)", value: "__none__", available: true }];
161
+ for (const a of available) {
162
+ if (a.name === role.primary) continue;
163
+ opts.push({ label: a.name, value: a.name, available: true });
164
+ }
165
+ const choice = await wizard.select(` Fallback de ${role.label} cuando ${role.primary} se quede sin créditos:`, opts);
166
+ if (choice === "__none__") {
167
+ delete config.roles[role.key].fallback;
168
+ logger.info(` -> ${role.key}: sin fallback (hibernará)`);
169
+ } else {
170
+ config.roles[role.key].fallback = { provider: choice, max_wait_hours: maxHours };
171
+ logger.info(` -> ${role.key}: ${role.primary} → ${choice} (tras ${maxHours}h)`);
172
+ }
173
+ }
174
+ }
175
+
130
176
  async function askMethodology(wizard, config, logger) {
131
177
  const methodology = await wizard.select("Development methodology:", [
132
178
  { label: "TDD (test-driven development)", value: "tdd", available: true },
@@ -256,6 +302,7 @@ async function runWizard(config, logger) {
256
302
  logger.info(` -> HU Board: ${enableHuBoard ? "enabled (auto-start on kj run)" : "disabled"}`);
257
303
 
258
304
  await askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger);
305
+ await askFallbackChain(wizard, available, config, { coder, reviewer }, logger);
259
306
  await askMethodology(wizard, config, logger);
260
307
  await askLanguages(wizard, config, logger);
261
308
 
@@ -25,16 +25,31 @@ export function formatPlan(plan) {
25
25
  return lines.join("\n");
26
26
  }
27
27
 
28
+ /**
29
+ * Convierte el id largo de una dep `hu_<planId>_<NNN>` al short_id de
30
+ * la HU correspondiente cuando lo conoce. Mantiene el id largo si la
31
+ * dep no tiene short_id (HU legacy pre-humanización).
32
+ */
33
+ function displayDep(depId, hus) {
34
+ const hu = hus.find((h) => h.id === depId);
35
+ if (hu?.short_id) return hu.short_id;
36
+ return depId;
37
+ }
38
+
28
39
  export function formatHuTable(hus) {
29
40
  if (!hus?.length) return " (no HUs)";
41
+ // Humanización IDs: si la HU tiene short_id ("INFRA-001"), úsalo
42
+ // como columna ID; si no, fallback al id largo (HU legacy).
43
+ const idOf = (h) => h.short_id || h.id;
30
44
  const lines = [];
45
+ const maxId = Math.min(20, Math.max(...hus.map((h) => idOf(h).length || 0)));
31
46
  const maxTitle = Math.min(50, Math.max(...hus.map(h => h.title?.length || 0)));
32
- lines.push(` ${"ID".padEnd(30)} ${"Title".padEnd(maxTitle)} ${"Type".padEnd(10)} ${"Status".padEnd(10)} Deps`);
33
- lines.push(` ${"─".repeat(30)} ${"─".repeat(maxTitle)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(15)}`);
47
+ lines.push(` ${"ID".padEnd(maxId)} ${"Title".padEnd(maxTitle)} ${"Type".padEnd(10)} ${"Status".padEnd(10)} Deps`);
48
+ lines.push(` ${"─".repeat(maxId)} ${"─".repeat(maxTitle)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(15)}`);
34
49
  for (const hu of hus) {
35
50
  const title = (hu.title || "").slice(0, maxTitle).padEnd(maxTitle);
36
- const deps = (hu.blocked_by || []).join(", ") || "—";
37
- lines.push(` ${hu.id.padEnd(30)} ${title} ${(hu.task_type || "sw").padEnd(10)} ${(hu.status || "?").padEnd(10)} ${deps}`);
51
+ const deps = (hu.blocked_by || []).map((d) => displayDep(d, hus)).join(", ") || "—";
52
+ lines.push(` ${idOf(hu).padEnd(maxId)} ${title} ${(hu.task_type || "sw").padEnd(10)} ${(hu.status || "?").padEnd(10)} ${deps}`);
38
53
  }
39
54
  return lines.join("\n");
40
55
  }
@@ -0,0 +1,99 @@
1
+ // KJC-TSK-0402: relanzar el self-fix loop sobre un plan ya generado,
2
+ // opcionalmente con feedback humano que guía al reviewer hacia los
3
+ // puntos débiles que la primera pasada no detectó. NO regenera desde
4
+ // cero — preserva el trabajo previo.
5
+
6
+ import { createAgent } from "../../agents/index.js";
7
+ import { assertAgentsAvailable } from "../../agents/availability.js";
8
+ import { resolveRole } from "../../config.js";
9
+ import { createCliProgressReporter } from "../../utils/cli-progress.js";
10
+ import { reviewPlan, findingsCount, findingsCountByType } from "../../plan/plan-reviewer.js";
11
+ import { applyReviewerFeedback, applyFixerPatch } from "../../plan/plan-fixer.js";
12
+ import { runStructuralPass } from "../../plan/plan-structural-pass.js";
13
+ import { loadPlan, savePlan } from "../../plan/plan-store.js";
14
+
15
+ export async function planFixCommand({ config, planId, prompt, logger, json }) {
16
+ const projectDir = config.projectDir || process.cwd();
17
+ const plan = await loadPlan(projectDir, planId);
18
+ if (!plan) throw new Error(`Plan no encontrado: ${planId}`);
19
+ if (!Array.isArray(plan.hus) || plan.hus.length === 0) {
20
+ throw new Error(`Plan ${planId} no tiene HUs.`);
21
+ }
22
+
23
+ const before = { hus: plan.hus.length, deps: plan.hus.reduce((a, h) => a + (h.blocked_by?.length || 0), 0) };
24
+
25
+ const plannerRole = resolveRole(config, "planner");
26
+ await assertAgentsAvailable([plannerRole.provider]);
27
+ const planner = createAgent(plannerRole.provider, config, logger);
28
+ const silenceTimeoutMs = Number(config?.session?.max_agent_silence_minutes) > 0
29
+ ? Math.round(Number(config.session.max_agent_silence_minutes) * 60 * 1000) : undefined;
30
+ const timeoutMs = Number(config?.session?.max_planner_minutes) > 0
31
+ ? Math.round(Number(config.session.max_planner_minutes) * 60 * 1000) : undefined;
32
+
33
+ // Reviewer con prompt opcional del usuario inyectado como contexto extra.
34
+ // El prompt se prepende al task original para que el reviewer LLM lo lea
35
+ // antes de la HU list.
36
+ const enrichedTask = prompt
37
+ ? `${plan.task || ""}\n\n## USER FEEDBACK (priorizar estos puntos)\n${prompt}\n`
38
+ : plan.task || "";
39
+
40
+ const reviewProgress = createCliProgressReporter({ role: "plan-fix:reviewer", quiet: Boolean(json) });
41
+ const review = await reviewPlan({
42
+ agent: planner, task: enrichedTask, hus: plan.hus,
43
+ onOutput: reviewProgress.onOutput, silenceTimeoutMs, timeoutMs,
44
+ });
45
+ reviewProgress.finish(review.ok ? "done" : "failed");
46
+ if (!review.ok) throw new Error(`Reviewer falló: ${review.error}`);
47
+ plan.review = review.findings;
48
+ let count = findingsCount(review.findings);
49
+ if (!json) process.stderr.write(`Reviewer encontró ${count} item(s).\n`);
50
+
51
+ // Self-fix loop con smart convergence guard (mismo que generate.js).
52
+ const maxIter = 4;
53
+ for (let i = 1; i <= maxIter && count > 0; i++) {
54
+ const husSnapshot = JSON.parse(JSON.stringify(plan.hus));
55
+ const reviewSnapshot = JSON.parse(JSON.stringify(plan.review));
56
+ const fixProgress = createCliProgressReporter({ role: `plan-fix:iter ${i}`, quiet: Boolean(json) });
57
+ const fix = await applyReviewerFeedback({
58
+ agent: planner, task: enrichedTask, hus: plan.hus, findings: plan.review,
59
+ onOutput: fixProgress.onOutput, silenceTimeoutMs, timeoutMs,
60
+ });
61
+ if (!fix.ok) { fixProgress.finish("failed"); break; }
62
+ const ops = applyFixerPatch(plan, fix.patch);
63
+ if (ops.added + ops.depsAdded + ops.deleted === 0) { fixProgress.finish("done"); break; }
64
+ const review2 = await reviewPlan({
65
+ agent: planner, task: enrichedTask, hus: plan.hus,
66
+ onOutput: fixProgress.onOutput, silenceTimeoutMs, timeoutMs,
67
+ });
68
+ if (!review2.ok) { plan.hus = husSnapshot; plan.review = reviewSnapshot; fixProgress.finish("failed"); break; }
69
+ const b = findingsCountByType(reviewSnapshot);
70
+ const a = findingsCountByType(review2.findings);
71
+ const accept = a.priority < b.priority || (a.priority === b.priority && a.total <= b.total);
72
+ if (!accept) { plan.hus = husSnapshot; plan.review = reviewSnapshot; fixProgress.finish("reverted"); break; }
73
+ plan.review = review2.findings;
74
+ count = a.total;
75
+ fixProgress.finish("done");
76
+ }
77
+
78
+ // Structural integrity pass determinístico al final.
79
+ const { fixes: structuralFixes } = runStructuralPass(plan);
80
+ if (structuralFixes.length > 0) {
81
+ if (!plan.review) plan.review = {};
82
+ plan.review.structural_fixes = structuralFixes;
83
+ }
84
+
85
+ await savePlan(projectDir, plan);
86
+ const after = { hus: plan.hus.length, deps: plan.hus.reduce((a, h) => a + (h.blocked_by?.length || 0), 0) };
87
+
88
+ if (json) {
89
+ console.log(JSON.stringify({ planId, before, after, structuralFixes }, null, 2));
90
+ return;
91
+ }
92
+ const diffHus = after.hus - before.hus;
93
+ const diffDeps = after.deps - before.deps;
94
+ const structuralSummary = structuralFixes.length > 0
95
+ ? `, ${structuralFixes.length} structural fix(es)` : "";
96
+ console.log(
97
+ `kj plan fix ${planId}: ${diffHus >= 0 ? "+" : ""}${diffHus} HUs, ${diffDeps >= 0 ? "+" : ""}${diffDeps} deps${structuralSummary}. Findings: ${count} restantes.`
98
+ );
99
+ }
@@ -8,6 +8,9 @@ import { runAutoGC, summarizeGC } from "../../utils/garbage-collector.js";
8
8
  import { promptProjectName } from "../../utils/prompt-project-name.js";
9
9
  import { deriveProjectNameFromCwd } from "../../utils/derive-project-name-from-cwd.js";
10
10
  import { applyReviewerFeedback, applyFixerPatch } from "../../plan/plan-fixer.js";
11
+ import { runStructuralPass } from "../../plan/plan-structural-pass.js";
12
+ import { recommendModelsForHu, complexityFromTaskType } from "../../hu/model-router.js";
13
+ import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
11
14
  import { formatPlan, formatHuTable } from "./_shared.js";
12
15
 
13
16
  /**
@@ -51,14 +54,23 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
51
54
  // Live progress to stderr so the user sees what the planner is doing
52
55
  // instead of staring at a silent shell for 30 s – 3 min. `--json` mode
53
56
  // stays quiet so stdout remains a pure JSON document.
54
- const progress = createCliProgressReporter({ role: "planner", quiet: Boolean(json) });
57
+ // Sub-progress por fase (KJC outputs limpios): cada llamada al
58
+ // agente arranca su propio "[planner:fase] starting → done" para
59
+ // que el usuario no piense que "[planner] done" significa "el
60
+ // comando entero terminó" — sigue habiendo tests-synth, reviewer,
61
+ // self-fix iter N por delante.
62
+ const progress = createCliProgressReporter({ role: "planner:initial", quiet: Boolean(json) });
55
63
  let result;
56
64
  try {
57
- result = await planner.runTask({
58
- prompt, role: "planner", silenceTimeoutMs, timeoutMs,
59
- onOutput: progress.onOutput,
65
+ // KJC-TSK-0413: planner inicial pasa por Brain Recovery — clasifica
66
+ // 401/429/5xx/SILENCED/etc y decide retry/standby/hibernate/abort.
67
+ result = await withBrainRecovery({
68
+ agent: planner,
69
+ taskArgs: { prompt, role: "planner", silenceTimeoutMs, timeoutMs, onOutput: progress.onOutput },
70
+ role: "planner",
71
+ logger,
60
72
  });
61
- progress.finish(result.ok ? "done" : "failed");
73
+ progress.finish(result.ok ? "done" : (result.action || "failed"));
62
74
  } catch (err) {
63
75
  progress.finish("failed");
64
76
  throw err;
@@ -124,19 +136,38 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
124
136
  const rawTests = typeof step === "object" ? step.acceptance_tests : null;
125
137
  const tests = normaliseAcceptanceTests(rawTests);
126
138
  if (tests.length === 0) stepsMissingTests += 1;
139
+ // Humanización IDs: preservar el id simbólico del planner ("INFRA-001",
140
+ // "AUTH-SIGNUP", etc.) como short_id del HU. La clave canónica
141
+ // sigue siendo el `hu.id` largo (`hu_<planId>_<NNN>`); el short_id
142
+ // solo da una etiqueta amigable que la CLI muestra y acepta como
143
+ // referencia en `kj run --hu INFRA-001`.
144
+ const symbolicId = typeof step === "object" && typeof step.id === "string" && step.id.trim()
145
+ ? step.id.trim() : null;
146
+ const taskType = classifyTaskType(desc);
147
+ // KJC-TSK-0405 step 2: asigna coder_model + reviewer_model
148
+ // automáticamente según complexity inferido del task_type. El
149
+ // planner puede emitir `step.complexity` (trivial/simple/medium/complex
150
+ // o score 1-5) para override más fino. Cross-provider reviewer
151
+ // (claude↔codex) por defecto.
152
+ const coderProvider = config?.roles?.coder?.provider || config?.coder || "claude";
153
+ const complexity = (typeof step === "object" && step.complexity)
154
+ ? step.complexity
155
+ : complexityFromTaskType(taskType);
156
+ const models = recommendModelsForHu({ complexity, coderProvider, config });
127
157
  const hu = addHu(plan, {
128
158
  title: desc.slice(0, 80),
129
- task_type: classifyTaskType(desc),
159
+ task_type: taskType,
130
160
  scope: desc,
131
161
  blocked_by: [],
132
162
  acceptance_tests: tests,
133
- // Spec mapping (PR C): preserve the planner's section citation
134
- // so the coder/reviewer / board can show "implements §5.3".
163
+ short_id: symbolicId,
135
164
  spec_section: typeof step === "object" && typeof step.spec_section === "string" ? step.spec_section.trim() || null : null,
165
+ coder_model: models.coder_model,
166
+ coder_provider: models.coder_provider,
167
+ reviewer_model: models.reviewer_model,
168
+ reviewer_provider: models.reviewer_provider,
136
169
  });
137
- if (typeof step === "object" && typeof step.id === "string" && step.id.trim()) {
138
- symbolicToHuId.set(step.id.trim(), hu.id);
139
- }
170
+ if (symbolicId) symbolicToHuId.set(symbolicId, hu.id);
140
171
  const deps = typeof step === "object" && Array.isArray(step.dependencies) ? step.dependencies : [];
141
172
  stepDeps.push(deps);
142
173
  const reuse = typeof step === "object" && Array.isArray(step.reuse) ? step.reuse : [];
@@ -169,7 +200,10 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
169
200
  runLog.logText(
170
201
  `[planner] ${stepsMissingTests}/${steps.length} HUs missing acceptance_tests — running tests-synthesizer pass`
171
202
  );
172
- progress.onOutput?.({ line: `${stepsMissingTests} HU(s) missing tests — running synthesizer pass`, kind: "text" });
203
+ // Sub-progress reporter por fase: el usuario ve cada fase como su
204
+ // propio "[planner:X] starting → done" en vez de un solo
205
+ // "[planner] done" que oculta que aún hay trabajo en cola.
206
+ const synthProgress = createCliProgressReporter({ role: "planner:tests-synth", quiet: Boolean(json) });
173
207
  const { synthesizeMissingTests } = await import("../../plan/tests-synthesizer.js");
174
208
  const husNeedingTests = plan.hus
175
209
  .filter((h) => !Array.isArray(h.acceptance_tests) || h.acceptance_tests.length === 0)
@@ -178,10 +212,11 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
178
212
  agent: planner,
179
213
  task,
180
214
  husNeedingTests,
181
- onOutput: progress.onOutput,
215
+ onOutput: synthProgress.onOutput,
182
216
  silenceTimeoutMs,
183
217
  timeoutMs,
184
218
  });
219
+ synthProgress.finish(synthResult.ok ? "done" : "failed");
185
220
  if (synthResult.ok) {
186
221
  let patched = 0;
187
222
  for (const hu of plan.hus) {
@@ -216,16 +251,17 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
216
251
  const skipReview = Boolean(flags?.noPlanReview || flags?.quick);
217
252
  if (plan.hus.length > 0 && !skipReview) {
218
253
  runLog.logText("[planner] running plan reviewer pass");
219
- progress.onOutput?.({ line: "Reviewing the plan for gaps / overlaps / order issues", kind: "text" });
220
- const { reviewPlan, findingsCount } = await import("../../plan/plan-reviewer.js");
254
+ const reviewProgress = createCliProgressReporter({ role: "planner:reviewer", quiet: Boolean(json) });
255
+ const { reviewPlan, findingsCount, findingsCountByType } = await import("../../plan/plan-reviewer.js");
221
256
  const review = await reviewPlan({
222
257
  agent: planner,
223
258
  task,
224
259
  hus: plan.hus,
225
- onOutput: progress.onOutput,
260
+ onOutput: reviewProgress.onOutput,
226
261
  silenceTimeoutMs,
227
262
  timeoutMs,
228
263
  });
264
+ reviewProgress.finish(review.ok ? "done" : "failed");
229
265
  if (review.ok) {
230
266
  plan.review = review.findings;
231
267
  const count = findingsCount(review.findings);
@@ -239,13 +275,18 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
239
275
  // PATCH the plan, apply in-process, and re-review. Loop until 0
240
276
  // findings or maxIterations. Skippable with --no-plan-fixer/--quick.
241
277
  const skipFixer = Boolean(flags?.noPlanFixer || flags?.quick);
242
- const maxFixerIterations = 2;
278
+ // KJC-BUG-0054: bumped 2 → 4 — el guard antiguo cortaba el loop
279
+ // demasiado pronto cuando había muchos hallazgos. Con el guard
280
+ // inteligente (priority vs secondary) podemos permitir más
281
+ // iteraciones sin riesgo de divergir indefinidamente, porque
282
+ // cada iter que NO reduce priority ni total se revierte.
283
+ const maxFixerIterations = 4;
243
284
  if (count > 0 && !skipFixer) {
244
285
  for (let i = 1; i <= maxFixerIterations; i++) {
245
286
  const currentCount = findingsCount(plan.review);
246
287
  if (currentCount === 0) break;
247
288
  runLog.logText(`[planner] self-fix iter ${i}/${maxFixerIterations} — patching ${currentCount} issue(s)`);
248
- progress.onOutput?.({ line: `Self-fix iter ${i}: patching ${currentCount} issue(s)`, kind: "text" });
289
+ const fixProgress = createCliProgressReporter({ role: `planner:self-fix iter ${i}`, quiet: Boolean(json) });
249
290
  // KJC-BUG-0046 / P5: snapshot plan + review BEFORE applying the patch
250
291
  // so we can revert if the iteration regresses (newCount > currentCount).
251
292
  // Dogfooding 2026-05-12: GRETA Plan 2 iter 2 went 10 → 17 findings
@@ -256,38 +297,66 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
256
297
  const reviewSnapshot = JSON.parse(JSON.stringify(plan.review));
257
298
  const fix = await applyReviewerFeedback({
258
299
  agent: planner, task, hus: plan.hus, findings: plan.review,
259
- onOutput: progress.onOutput, silenceTimeoutMs, timeoutMs,
300
+ onOutput: fixProgress.onOutput, silenceTimeoutMs, timeoutMs,
260
301
  });
261
302
  if (!fix.ok) {
303
+ fixProgress.finish("failed");
262
304
  runLog.logText(`[planner] self-fix iter ${i} failed (non-blocking): ${fix.error}`);
263
305
  break;
264
306
  }
265
307
  const ops = applyFixerPatch(plan, fix.patch);
266
308
  runLog.logText(`[planner] self-fix iter ${i} applied: +${ops.added} HU, +${ops.depsAdded} dep, -${ops.deleted} HU`);
267
309
  if (ops.added + ops.depsAdded + ops.deleted === 0) {
310
+ fixProgress.finish("done");
268
311
  runLog.logText(`[planner] self-fix iter ${i} empty patch — stopping`);
269
312
  break;
270
313
  }
271
314
  const review2 = await reviewPlan({
272
315
  agent: planner, task, hus: plan.hus,
273
- onOutput: progress.onOutput, silenceTimeoutMs, timeoutMs,
316
+ onOutput: fixProgress.onOutput, silenceTimeoutMs, timeoutMs,
274
317
  });
275
318
  if (!review2.ok) {
319
+ fixProgress.finish("failed");
276
320
  runLog.logText(`[planner] re-review after iter ${i} failed: ${review2.error}`);
277
321
  plan.hus = husSnapshot;
278
322
  plan.review = reviewSnapshot;
279
323
  break;
280
324
  }
281
- const newCount = findingsCount(review2.findings);
282
- if (newCount > currentCount) {
283
- // P5 convergence guard: iter made things worse — revert + stop.
325
+ // KJC-BUG-0054: smart convergence guard. El guard antiguo
326
+ // (newCount > currentCount → revert) tiraba iteraciones
327
+ // VÁLIDAS que arreglaban un ciclo o una HU faltante pero
328
+ // a cambio creaban deps menores. Ahora distinguimos:
329
+ // - priority = cycles + missing_hus (CRÍTICO, no ignorable)
330
+ // - secondary = missing_deps + overlaps + order non-circular
331
+ //
332
+ // Reglas (en orden):
333
+ // 1. priority bajó → ACEPTAR (aunque secondary suba — el plan es ejecutable)
334
+ // 2. priority igual + total bajó → ACEPTAR
335
+ // 3. priority igual + total igual → ACEPTAR (lateral move, da chance)
336
+ // 4. priority subió → REVERT
337
+ // 5. priority igual + total subió → REVERT (regresión genuina)
338
+ const before = findingsCountByType(reviewSnapshot);
339
+ const after = findingsCountByType(review2.findings);
340
+ const newCount = after.total;
341
+ const accept =
342
+ after.priority < before.priority
343
+ || (after.priority === before.priority && after.total <= before.total);
344
+ if (!accept) {
345
+ const reason = after.priority > before.priority
346
+ ? `priority ${before.priority}→${after.priority}`
347
+ : `total ${before.total}→${after.total} (priority unchanged at ${after.priority})`;
284
348
  plan.hus = husSnapshot;
285
349
  plan.review = reviewSnapshot;
286
- runLog.logText(`[planner] self-fix iter ${i} regressed (${currentCount} → ${newCount}) — reverted, stopping`);
350
+ fixProgress.finish("reverted");
351
+ runLog.logText(`[planner] self-fix iter ${i} regressed (${reason}) — reverted, stopping`);
287
352
  break;
288
353
  }
289
354
  plan.review = review2.findings;
290
- runLog.logText(`[planner] after self-fix iter ${i}: ${newCount} issue(s) remaining`);
355
+ fixProgress.finish("done");
356
+ const deltaSummary = after.priority < before.priority
357
+ ? `priority ${before.priority}→${after.priority}, total ${before.total}→${after.total}`
358
+ : `total ${before.total}→${after.total}`;
359
+ runLog.logText(`[planner] after self-fix iter ${i}: ${newCount} issue(s) (${deltaSummary})`);
291
360
  }
292
361
  }
293
362
  } else {
@@ -295,6 +364,19 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
295
364
  }
296
365
  }
297
366
 
367
+ // KJC-TSK-0399: deterministic structural integrity pass — cycles,
368
+ // orphan refs, missing short_id. Runs after self-fix loop because
369
+ // the LLM is bad at graph problems. Always runs (cheap, no network).
370
+ if (plan.hus.length > 0) {
371
+ const { fixes } = runStructuralPass(plan);
372
+ if (fixes.length > 0) {
373
+ if (!plan.review) plan.review = {};
374
+ plan.review.structural_fixes = fixes;
375
+ const byKind = fixes.reduce((a, f) => ({ ...a, [f.kind]: (a[f.kind] || 0) + 1 }), {});
376
+ runLog.logText(`[planner] structural pass: ${fixes.length} fix(es) — ${JSON.stringify(byKind)}`);
377
+ }
378
+ }
379
+
298
380
  const planId = await savePlan(projectDir, plan);
299
381
  const { plansDir } = await import("../../plan/plan-store.js");
300
382
  const path = await import("node:path");
@@ -341,9 +423,12 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
341
423
  console.log(`Reviewer flagged: ${counts.join(", ")} — see \`kj plan show ${planId}\``);
342
424
  }
343
425
  }
426
+ // Humanización IDs: si el plan tiene alias, lo preferimos como
427
+ // referencia visible (más legible). Fallback al planId.
428
+ const ref = plan.alias || planId;
344
429
  console.log("");
345
- console.log(`Inspect: kj plan show ${planId}`);
346
- console.log(`Run: kj run --plan ${planId} "${task.slice(0, 40)}..."`);
430
+ console.log(`Inspect: kj plan show ${ref}`);
431
+ console.log(`Run: kj run --plan ${ref}`);
347
432
  console.log(` (or click ▶ Run plan on the board, if running)`);
348
433
 
349
434
  // UX boost for HU-bearing plans: auto-start the HU Board (same pattern as
@@ -9,6 +9,8 @@ export { planValidateCommand } from "./validate.js";
9
9
  export { planDeleteCommand } from "./delete.js";
10
10
  export { planAddHuCommand } from "./add-hu.js";
11
11
  export { planRemoveHuCommand } from "./remove-hu.js";
12
+ export { planMigrateResultCommand } from "./migrate-result.js";
13
+ export { planFixCommand } from "./fix.js";
12
14
 
13
15
  // Keep backward compat — old callers import planCommand
14
16
  export const planCommand = planGenerateCommand;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * kj plan migrate-result [--dry-run] — KJC-TSK-0394 step 4.
3
+ * Siembra `result` en las HUs de los plan files existentes,
4
+ * deducido del status legacy. Idempotente.
5
+ */
6
+ import { listPlans, loadPlan, savePlan } from "../../plan/plan-store.js";
7
+ import { migratePlanResult } from "../../plan/plan-migrate-result.js";
8
+
9
+ export async function planMigrateResultCommand({ config, flags = {} }) {
10
+ const projectDir = config.projectDir || process.cwd();
11
+ const dryRun = !!flags.dryRun;
12
+
13
+ const plans = await listPlans(projectDir);
14
+ if (plans.length === 0) {
15
+ console.log("No hay plans que migrar en este proyecto.");
16
+ return;
17
+ }
18
+
19
+ let totalPlans = 0;
20
+ let totalHus = 0;
21
+ for (const meta of plans) {
22
+ const plan = await loadPlan(projectDir, meta.planId);
23
+ if (!plan) continue;
24
+ const { plan: migrated, changes } = migratePlanResult(plan);
25
+ if (changes.length === 0) continue;
26
+ totalPlans += 1;
27
+ totalHus += changes.length;
28
+ const summary = changes
29
+ .map((c) => `${c.huId}→${c.result ?? "null"}`)
30
+ .join(", ");
31
+ console.log(`${dryRun ? "[dry-run] " : ""}${meta.planId}: ${changes.length} HU(s) — ${summary}`);
32
+ if (!dryRun) await savePlan(projectDir, migrated);
33
+ }
34
+
35
+ if (totalPlans === 0) {
36
+ console.log("Todos los plans ya tienen `result` sembrado. Nada que hacer.");
37
+ } else {
38
+ const prefix = dryRun ? "[dry-run] " : "";
39
+ console.log(`${prefix}Migrados ${totalHus} HU(s) en ${totalPlans} plan(s).`);
40
+ }
41
+ }
@@ -10,5 +10,7 @@ export {
10
10
  planDeleteCommand,
11
11
  planAddHuCommand,
12
12
  planRemoveHuCommand,
13
+ planMigrateResultCommand,
14
+ planFixCommand,
13
15
  planCommand,
14
16
  } from "./plan/index.js";
@@ -2,6 +2,7 @@ import { createAgent } from "../agents/index.js";
2
2
  import { assertAgentsAvailable } from "../agents/availability.js";
3
3
  import { resolveRole } from "../config.js";
4
4
  import { createCliProgressReporter } from "../utils/cli-progress.js";
5
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
5
6
 
6
7
  const SUBAGENT_PREAMBLE = [
7
8
  "IMPORTANT: You are running as a Karajan sub-agent.",
@@ -33,8 +34,11 @@ export async function researcherCommand({ task, config, logger }) {
33
34
  const progress = createCliProgressReporter({ role: "researcher" });
34
35
  let result;
35
36
  try {
36
- result = await agent.runTask({ prompt, onOutput: progress.onOutput, role: "researcher" });
37
- progress.finish(result.ok ? "done" : "failed");
37
+ result = await withBrainRecovery({
38
+ agent, taskArgs: { prompt, onOutput: progress.onOutput, role: "researcher" },
39
+ role: "researcher", provider: researcherRole.provider, logger,
40
+ });
41
+ progress.finish(result.ok ? "done" : (result.action || "failed"));
38
42
  } catch (err) { progress.finish("failed"); throw err; }
39
43
 
40
44
  if (!result.ok) {
@@ -4,6 +4,7 @@ import { runFlow } from "../orchestrator.js";
4
4
  import { assertAgentsAvailable } from "../agents/availability.js";
5
5
  import { createActivityLog } from "../activity-log.js";
6
6
  import { withCliRunLog } from "../utils/cli-run-log.js";
7
+ import { registerRun, unregisterRun } from "../utils/run-registry.js";
7
8
  import { printHeader } from "../utils/display/header.js";
8
9
  import { printEvent } from "../utils/display/event-handlers.js";
9
10
  import { resolveRole } from "../config.js";
@@ -112,6 +113,28 @@ export async function runCommandHandler({ task, config, logger, flags }) {
112
113
  return withCliRunLog("run", { projectDir: config?.projectDir, logger }, async ({ runLog, forwardProgress }) => {
113
114
  runLog.logText(`[kj_run] task="${String(task).slice(0, 80)}..."`);
114
115
 
116
+ // KJC-TSK-0396 extensión: registrar el PID + planId + huIds en
117
+ // ~/.karajan/runs/<runId>.json para que el HU Board pueda mostrar
118
+ // el botón ⏹ Stop incluso cuando el run se lanza desde terminal
119
+ // externa (no solo desde el botón ▶ Run del board). Best-effort:
120
+ // si la escritura falla, el run sigue su curso normal. Limpieza
121
+ // garantizada en el finally del withCliRunLog vía registry.unregisterRun.
122
+ const huIdsFromFlag = typeof flags?.hu === "string"
123
+ ? flags.hu.split(",").map((s) => s.trim()).filter(Boolean)
124
+ : null;
125
+ const runId = registerRun({
126
+ pid: process.pid,
127
+ planId: flags?.plan || null,
128
+ huIds: huIdsFromFlag,
129
+ projectDir: config?.projectDir || process.cwd(),
130
+ source: "cli",
131
+ });
132
+ const cleanupRegistry = () => { if (runId) unregisterRun(runId); };
133
+ // Cubrir todos los caminos de salida del proceso.
134
+ process.once("exit", cleanupRegistry);
135
+ process.once("SIGTERM", () => { cleanupRegistry(); process.exit(143); });
136
+ process.once("SIGINT", () => { cleanupRegistry(); process.exit(130); });
137
+
115
138
  const emitter = new EventEmitter();
116
139
  let activityLog = null;
117
140
 
@@ -139,7 +162,16 @@ export async function runCommandHandler({ task, config, logger, flags }) {
139
162
  }
140
163
 
141
164
  const askQuestion = createCliAskQuestion();
142
- const result = await runFlow({ task: task, config, logger, flags, emitter, askQuestion, pgTaskId: pgCardId || null, pgProject: pgProject || null });
165
+ let result;
166
+ try {
167
+ result = await runFlow({ task: task, config, logger, flags, emitter, askQuestion, pgTaskId: pgCardId || null, pgProject: pgProject || null });
168
+ } finally {
169
+ // KJC-TSK-0396: limpia el registro pase lo que pase (éxito,
170
+ // throw, o paused). Los handlers de SIGINT/SIGTERM/exit son
171
+ // backstop para casos donde el throw no llega aquí (segfault,
172
+ // kill -9, etc.).
173
+ cleanupRegistry();
174
+ }
143
175
 
144
176
  if (jsonMode) {
145
177
  console.log(JSON.stringify(result, null, 2));