karajan-code 2.14.3 → 2.16.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 (75) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/agents/claude-agent.js +2 -2
  4. package/src/agents/codex-agent.js +1 -1
  5. package/src/audit/agent-readiness.js +1 -1
  6. package/src/audit/basal-cost.js +4 -4
  7. package/src/audit/webperf-input.js +1 -1
  8. package/src/brain/agent-error-classifier.js +101 -0
  9. package/src/brain/standby-scheduler.js +92 -0
  10. package/src/brain/standby-store.js +129 -0
  11. package/src/brain/with-brain-recovery.js +178 -0
  12. package/src/canvas/parse-md.js +1 -1
  13. package/src/cli/register-plan.js +29 -0
  14. package/src/cli/register-standby.js +26 -0
  15. package/src/cli.js +2 -0
  16. package/src/commands/architect.js +6 -2
  17. package/src/commands/audit.js +1 -1
  18. package/src/commands/discover.js +6 -2
  19. package/src/commands/init.js +47 -0
  20. package/src/commands/plan/_shared.js +19 -4
  21. package/src/commands/plan/fix.js +99 -0
  22. package/src/commands/plan/generate.js +112 -27
  23. package/src/commands/plan/index.js +2 -5
  24. package/src/commands/plan/migrate-result.js +41 -0
  25. package/src/commands/plan.js +2 -1
  26. package/src/commands/researcher.js +6 -2
  27. package/src/commands/run.js +33 -1
  28. package/src/commands/standby.js +114 -0
  29. package/src/commands/triage.js +6 -2
  30. package/src/commands/webperf.js +1 -1
  31. package/src/config/schema.js +40 -0
  32. package/src/domains/domain-synthesizer.js +1 -1
  33. package/src/git/hu-automation.js +2 -2
  34. package/src/git/hu-snapshot.js +105 -0
  35. package/src/guards/policy-resolver.js +38 -1
  36. package/src/hu/auto-generator.js +1 -1
  37. package/src/hu/lazy-planner.js +6 -1
  38. package/src/hu/model-router.js +108 -0
  39. package/src/hu/splitting-generator.js +6 -1
  40. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
  41. package/src/orchestrator/drivers/run-hu-batch.js +30 -4
  42. package/src/orchestrator/repair/repair-detector.js +3 -3
  43. package/src/plan/adr-generator.js +5 -5
  44. package/src/plan/plan-executor.js +13 -4
  45. package/src/plan/plan-fixer.js +36 -4
  46. package/src/plan/plan-hu-ops.js +24 -1
  47. package/src/plan/plan-id.js +21 -1
  48. package/src/plan/plan-migrate-result.js +24 -0
  49. package/src/plan/plan-reviewer.js +40 -3
  50. package/src/plan/plan-schema.js +128 -1
  51. package/src/plan/plan-store.js +64 -9
  52. package/src/plan/plan-structural-pass.js +122 -0
  53. package/src/plan/plan-validation.js +82 -0
  54. package/src/plan/tests-synthesizer.js +8 -3
  55. package/src/prompts/planner.js +27 -1
  56. package/src/roles/agent-role.js +18 -3
  57. package/src/roles/perf-role.js +1 -1
  58. package/src/roles/planner-role.js +10 -3
  59. package/src/roles/solomon-role.js +10 -4
  60. package/src/session/journal/decisions-writer.js +1 -1
  61. package/src/session/journal/iteration-logger.js +1 -1
  62. package/src/session/journal/summary-writer.js +1 -1
  63. package/src/skills/addyosmani-catalog.js +1 -1
  64. package/src/skills/semantic-detector.js +17 -2
  65. package/src/skills/skills-cache.js +1 -1
  66. package/src/sonar/api.js +22 -4
  67. package/src/sonar/config-resolver.js +1 -1
  68. package/src/sonar/issue-filter.js +110 -0
  69. package/src/sonar/scanner.js +1 -1
  70. package/src/utils/cli-progress.js +58 -1
  71. package/src/utils/derive-project-name-from-cwd.js +3 -3
  72. package/src/utils/garbage-collector.js +85 -2
  73. package/src/utils/injection-guard.js +1 -1
  74. package/src/utils/messages.js +1 -1
  75. package/src/utils/run-registry.js +115 -0
@@ -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));
@@ -0,0 +1,114 @@
1
+ // KJC-TSK-0414 PR2: comandos kj standby list / kj resume.
2
+ //
3
+ // `kj resume <sessionId>` valida cooldownUntil y, si llegó la hora, re-spawn
4
+ // el comando original capturado en argv. Si cooldown todavía está en el
5
+ // futuro, muestra countdown y aborta. `kj standby list` solo enumera.
6
+ //
7
+ // Estrategia de resume: cada sessionState persiste su argv original (los
8
+ // argumentos con los que se lanzó el `kj` que hibernó). El resume re-spawn
9
+ // EXACTAMENTE ese mismo comando con env preservado. El run reanuda como si
10
+ // fuera un retry — el state interno (HU pendiente, snapshot_sha, etc.) está
11
+ // en el JSON persistido en disco por TSK-0408 + TSK-0414 PR1.
12
+
13
+ import { spawn } from "node:child_process";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { loadStandby, listPendingStandby, markStandbyDone, acquireStandbyLock } from "../brain/standby-store.js";
17
+
18
+ const REPO_KJ_CLI = (() => {
19
+ try {
20
+ const here = path.dirname(fileURLToPath(import.meta.url));
21
+ return path.join(here, "..", "cli.js");
22
+ } catch {
23
+ return null;
24
+ }
25
+ })();
26
+
27
+ function formatCountdown(ms) {
28
+ if (ms <= 0) return "ahora";
29
+ const s = Math.round(ms / 1000);
30
+ const h = Math.floor(s / 3600);
31
+ const m = Math.floor((s % 3600) / 60);
32
+ const sec = s % 60;
33
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
34
+ }
35
+
36
+ export async function standbyListCommand({ json } = {}) {
37
+ const sessions = listPendingStandby();
38
+ if (json) {
39
+ console.log(JSON.stringify(sessions, null, 2));
40
+ return;
41
+ }
42
+ if (sessions.length === 0) {
43
+ console.log("No hay sesiones hibernadas.");
44
+ return;
45
+ }
46
+ console.log(`Sesiones hibernadas (${sessions.length}):`);
47
+ const now = Date.now();
48
+ for (const s of sessions) {
49
+ const ms = new Date(s.cooldownUntil).getTime() - now;
50
+ const status = ms <= 0 ? "✓ lista para resume" : `⏳ resume en ${formatCountdown(ms)}`;
51
+ console.log(` ${s.sessionId} · ${s.reason || "?"} · ${status}`);
52
+ if (s.planId) console.log(` plan: ${s.planId}${s.huId ? `, HU: ${s.huId}` : ""}`);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Re-spawn el comando original capturado en sessionState.argv.
58
+ * Si cooldownUntil > now y force=false → aborta con mensaje.
59
+ */
60
+ export async function resumeCommand({ sessionId, force } = {}) {
61
+ if (!sessionId) {
62
+ console.error("kj resume requiere <sessionId>. Usa `kj standby list` para ver pendientes.");
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+ const state = loadStandby(sessionId);
67
+ if (!state) {
68
+ console.error(`Sesión no encontrada: ${sessionId}`);
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+ const cooldownMs = new Date(state.cooldownUntil).getTime() - Date.now();
73
+ if (cooldownMs > 0 && !force) {
74
+ console.log(`Sesión ${sessionId} todavía en cooldown ${formatCountdown(cooldownMs)} (hasta ${state.cooldownUntil}).`);
75
+ console.log("Espera o usa `kj resume <sessionId> --force` (no recomendado — la API seguramente seguirá rechazando).");
76
+ process.exitCode = 0;
77
+ return;
78
+ }
79
+
80
+ const lock = acquireStandbyLock(sessionId);
81
+ if (!lock.acquired) {
82
+ console.log(`Sesión ${sessionId} ya está siendo reanudada por otro proceso.`);
83
+ process.exitCode = 0;
84
+ return;
85
+ }
86
+
87
+ try {
88
+ if (!Array.isArray(state.argv) || state.argv.length === 0) {
89
+ console.error(`Sesión ${sessionId} sin argv — no se puede re-spawn. Borra manual y relanza.`);
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ if (!REPO_KJ_CLI) {
94
+ console.error("No se pudo localizar cli.js. ¿Karajan instalado correctamente?");
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+ console.log(`Reanudando ${sessionId}: kj ${state.argv.join(" ")}`);
99
+ const child = spawn(process.execPath, [REPO_KJ_CLI, ...state.argv], {
100
+ cwd: state.cwd || process.cwd(),
101
+ env: { ...process.env, ...(state.env || {}), KJ_RESUME_FROM: sessionId },
102
+ stdio: "inherit",
103
+ });
104
+ await new Promise((resolve) => {
105
+ child.on("exit", (code) => {
106
+ if (code === 0) markStandbyDone(sessionId);
107
+ process.exitCode = code ?? 0;
108
+ resolve();
109
+ });
110
+ });
111
+ } finally {
112
+ lock.release?.();
113
+ }
114
+ }
@@ -4,6 +4,7 @@ import { resolveRole } from "../config.js";
4
4
  import { createCliProgressReporter } from "../utils/cli-progress.js";
5
5
  import { buildTriagePrompt } from "../prompts/triage.js";
6
6
  import { parseMaybeJsonString } from "../review/parser.js";
7
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
7
8
 
8
9
  function formatTriage(result) {
9
10
  const lines = [];
@@ -47,8 +48,11 @@ export async function triageCommand({ task, config, logger, json }) {
47
48
  const progress = createCliProgressReporter({ role: "triage" });
48
49
  let result;
49
50
  try {
50
- result = await agent.runTask({ prompt, onOutput: progress.onOutput, role: "triage" });
51
- progress.finish(result.ok ? "done" : "failed");
51
+ result = await withBrainRecovery({
52
+ agent, taskArgs: { prompt, onOutput: progress.onOutput, role: "triage" },
53
+ role: "triage", provider: triageRole.provider, logger,
54
+ });
55
+ progress.finish(result.ok ? "done" : (result.action || "failed"));
52
56
  } catch (err) { progress.finish("failed"); throw err; }
53
57
 
54
58
  if (!result.ok) {
@@ -26,7 +26,7 @@ function defaultReportDir() {
26
26
  * with audit history under ~/.karajan/.
27
27
  */
28
28
  function projectSlug(projectDir) {
29
- return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
29
+ return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
30
30
  }
31
31
 
32
32
  function formatHumanReport(result) {
@@ -53,12 +53,27 @@ const Methodology = v.picklist(
53
53
  "development.methodology must be one of: tdd | standard"
54
54
  );
55
55
 
56
+ /**
57
+ * KJC-TSK-0415: fallback chain. Cuando provider primario hits
58
+ * QUOTA_EXHAUSTED_* con cooldown > max_wait_hours, switch a fallback.
59
+ * Recursivo — el fallback puede tener su propio fallback.
60
+ */
61
+ const RoleFallback = v.looseObject({
62
+ provider: v.string("fallback.provider required (claude|codex|gemini|opencode|aider)"),
63
+ model: v.optional(v.nullable(v.string())),
64
+ max_wait_hours: v.optional(v.pipe(v.number(), v.minValue(0)), 12),
65
+ // Recursivo. Schema permite cualquier shape compatible con RoleFallback.
66
+ fallback: v.optional(v.lazy(() => RoleFallback)),
67
+ });
68
+
56
69
  /**
57
70
  * Role entry — provider + model, both nullable. Extra keys allowed.
71
+ * KJC-TSK-0415: campo fallback opcional para Plan B en quota exhausted.
58
72
  */
59
73
  const RoleEntry = v.looseObject({
60
74
  provider: v.optional(v.nullable(v.string())),
61
75
  model: v.optional(v.nullable(v.string())),
76
+ fallback: v.optional(RoleFallback),
62
77
  });
63
78
 
64
79
  const RolesMap = v.optional(v.looseObject({
@@ -176,6 +191,30 @@ const Skills = v.optional(v.looseObject({
176
191
  addyosmani: v.optional(SkillsAddyosmani),
177
192
  }));
178
193
 
194
+ /**
195
+ * KJC-TSK-0407: routing automático de modelos por HU según complexity.
196
+ * - by_provider: override de tiers per provider (claude/codex/gemini).
197
+ * Cada provider mapea level → modelo. Sobreescribe defaults del router.
198
+ * - fixed.coder / fixed.reviewer: si presentes, override TOTAL — todas
199
+ * las HUs usan esos modelos independientemente del score. Útil para
200
+ * forzar Sonnet en todo el proyecto, por ejemplo.
201
+ */
202
+ const ModelRouting = v.optional(v.looseObject({
203
+ by_provider: v.optional(v.record(
204
+ v.string(),
205
+ v.looseObject({
206
+ trivial: v.optional(v.string()),
207
+ simple: v.optional(v.string()),
208
+ medium: v.optional(v.string()),
209
+ complex: v.optional(v.string()),
210
+ })
211
+ )),
212
+ fixed: v.optional(v.looseObject({
213
+ coder: v.optional(v.nullable(v.string())),
214
+ reviewer: v.optional(v.nullable(v.string())),
215
+ })),
216
+ }));
217
+
179
218
  /**
180
219
  * Main config schema. `looseObject` lets every consumer that reads an
181
220
  * undeclared key keep working while the schema catches the bugs we care
@@ -207,6 +246,7 @@ export const ConfigSchema = v.looseObject({
207
246
  policies: v.optional(v.record(v.string(), v.unknown())),
208
247
  telemetry: v.optional(v.boolean()),
209
248
  skills: Skills,
249
+ model_routing: ModelRouting,
210
250
  });
211
251
 
212
252
  /**
@@ -80,7 +80,7 @@ function buildKeywords(task, hints) {
80
80
 
81
81
  const taskWords = task
82
82
  .toLowerCase()
83
- .replace(/[^a-z0-9\s-]/g, " ")
83
+ .replaceAll(/[^a-z0-9\s-]/g, " ")
84
84
  .split(/\s+/)
85
85
  .filter(w => w.length > 3 && !stopWords.has(w));
86
86
 
@@ -14,8 +14,8 @@ import { runCommand } from "../utils/process.js";
14
14
  export function buildHuBranchName(prefix, story) {
15
15
  const baseSlug = String(story.title || story.id || "hu")
16
16
  .toLowerCase()
17
- .replace(/[^a-z0-9]+/g, "-")
18
- .replace(/^-+|-+$/g, "")
17
+ .replaceAll(/[^a-z0-9]+/g, "-")
18
+ .replaceAll(/^-+|-+$/g, "")
19
19
  .slice(0, 40);
20
20
  return `${prefix}${story.id}-${baseSlug}`;
21
21
  }
@@ -0,0 +1,105 @@
1
+ // KJC-TSK-0408: snapshots de ficheros por HU para soportar Undo.
2
+ //
3
+ // Antes de ejecutar una HU, guardamos un ref git apuntando al HEAD
4
+ // actual (`refs/kj-snapshots/<huId>`). Si el usuario hace Undo después,
5
+ // hacemos `git reset --hard <ref>` para volver al estado pre-run.
6
+ //
7
+ // Por qué refs en lugar de stash/tarball:
8
+ // - Refs son objetos git nativos. No mueven blobs (todos compartidos
9
+ // con HEAD), así que crear el snapshot es O(1).
10
+ // - El snapshot persiste a través de checkouts y rebases.
11
+ // - Si el ref ya apunta al SHA correcto, restore es no-op.
12
+ //
13
+ // El runner es inyectable para que los tests no necesiten un repo git real.
14
+
15
+ import { runCommand } from "../utils/process.js";
16
+
17
+ const REF_PREFIX = "refs/kj-snapshots/";
18
+
19
+ /**
20
+ * Sanitiza un huId para uso como nombre de ref. Git rechaza:
21
+ * - `..`, `:`, `~`, `^`, `?`, `*`, `[`, `\`, espacios, control chars
22
+ * - segmentos que empiecen por `.` o terminen en `.lock`
23
+ * Reemplazamos cualquier no [a-zA-Z0-9_-] por `_`.
24
+ */
25
+ export function snapshotRefForHu(huId) {
26
+ if (!huId) throw new Error("huId requerido");
27
+ const safe = String(huId).replaceAll(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
28
+ return REF_PREFIX + safe;
29
+ }
30
+
31
+ async function defaultRunner(args, opts = {}) {
32
+ return runCommand("git", args, opts);
33
+ }
34
+
35
+ /**
36
+ * Crea (o actualiza) el ref del snapshot apuntando al HEAD actual.
37
+ *
38
+ * @param {object} args
39
+ * @param {string} args.projectDir
40
+ * @param {string} args.huId
41
+ * @param {Function} [args.runner] - sustituye git CLI en tests
42
+ * @returns {Promise<{ ok: boolean, ref?: string, sha?: string, error?: string }>}
43
+ */
44
+ export async function createHuSnapshot({ projectDir, huId, runner }) {
45
+ const run = runner || defaultRunner;
46
+ const ref = snapshotRefForHu(huId);
47
+ const head = await run(["rev-parse", "HEAD"], { cwd: projectDir });
48
+ if (head.exitCode !== 0) {
49
+ return { ok: false, error: `rev-parse HEAD failed: ${(head.stderr || "").trim()}` };
50
+ }
51
+ const sha = (head.stdout || "").trim();
52
+ if (!sha) return { ok: false, error: "HEAD vacío" };
53
+ const update = await run(["update-ref", ref, sha], { cwd: projectDir });
54
+ if (update.exitCode !== 0) {
55
+ return { ok: false, error: `update-ref falló: ${(update.stderr || "").trim()}` };
56
+ }
57
+ return { ok: true, ref, sha };
58
+ }
59
+
60
+ /**
61
+ * @param {object} args
62
+ * @param {string} args.projectDir
63
+ * @param {string} args.huId
64
+ * @param {Function} [args.runner]
65
+ * @returns {Promise<{ ok: boolean, sha?: string }>}
66
+ */
67
+ export async function hasHuSnapshot({ projectDir, huId, runner }) {
68
+ const run = runner || defaultRunner;
69
+ const ref = snapshotRefForHu(huId);
70
+ const r = await run(["rev-parse", "--verify", "--quiet", ref], { cwd: projectDir });
71
+ if (r.exitCode !== 0) return { ok: false };
72
+ return { ok: true, sha: (r.stdout || "").trim() };
73
+ }
74
+
75
+ /**
76
+ * Restaura el workspace al snapshot. DESTRUCTIVO: hace `git reset --hard`,
77
+ * lo que descarta cambios no committeados. Caller debe avisar al usuario.
78
+ *
79
+ * @returns {Promise<{ ok: boolean, sha?: string, error?: string }>}
80
+ */
81
+ export async function restoreHuSnapshot({ projectDir, huId, runner }) {
82
+ const run = runner || defaultRunner;
83
+ const ref = snapshotRefForHu(huId);
84
+ const has = await hasHuSnapshot({ projectDir, huId, runner: run });
85
+ if (!has.ok) return { ok: false, error: `snapshot no encontrado para HU ${huId}` };
86
+ const r = await run(["reset", "--hard", ref], { cwd: projectDir });
87
+ if (r.exitCode !== 0) {
88
+ return { ok: false, error: `reset --hard falló: ${(r.stderr || "").trim()}` };
89
+ }
90
+ return { ok: true, sha: has.sha };
91
+ }
92
+
93
+ /**
94
+ * Borra el ref del snapshot. Idempotente: si no existe, devuelve ok:true.
95
+ */
96
+ export async function removeHuSnapshot({ projectDir, huId, runner }) {
97
+ const run = runner || defaultRunner;
98
+ const ref = snapshotRefForHu(huId);
99
+ const r = await run(["update-ref", "-d", ref], { cwd: projectDir });
100
+ // git update-ref -d devuelve 0 incluso si el ref no existía.
101
+ if (r.exitCode !== 0) {
102
+ return { ok: false, error: `update-ref -d falló: ${(r.stderr || "").trim()}` };
103
+ }
104
+ return { ok: true };
105
+ }
@@ -6,7 +6,14 @@
6
6
  * @typedef {import("../types/config.js").KarajanConfig} KarajanConfig
7
7
  */
8
8
 
9
- export const VALID_TASK_TYPES = new Set(["sw", "infra", "doc", "add-tests", "refactor", "audit", "analysis", "no-code"]);
9
+ // KJC-TSK-0400: spike y research añadidos como tipos no-code. Comparten
10
+ // las defaults restrictivas (tdd/sonar/testsRequired off) porque NO
11
+ // producen código que medir: un SPIKE es un .md de decisión, un research
12
+ // es una nota de investigación. Forzar Sonar les hace fallar siempre.
13
+ export const VALID_TASK_TYPES = new Set([
14
+ "sw", "infra", "doc", "add-tests", "refactor",
15
+ "audit", "analysis", "no-code", "spike", "research",
16
+ ]);
10
17
 
11
18
  export const DEFAULT_POLICIES = {
12
19
  sw: { tdd: true, sonar: true, reviewer: true, testsRequired: true },
@@ -17,8 +24,38 @@ export const DEFAULT_POLICIES = {
17
24
  audit: { tdd: false, sonar: false, reviewer: false, testsRequired: false, coderRequired: false },
18
25
  analysis: { tdd: false, sonar: false, reviewer: false, testsRequired: false, coderRequired: false },
19
26
  "no-code": { tdd: false, sonar: false, reviewer: true, testsRequired: false, coderRequired: true },
27
+ spike: { tdd: false, sonar: false, reviewer: true, testsRequired: false, coderRequired: true },
28
+ research: { tdd: false, sonar: false, reviewer: true, testsRequired: false, coderRequired: true },
20
29
  };
21
30
 
31
+ // KJC-TSK-0400: si el title arranca con [SPIKE] / [DOC] / [RESEARCH] /
32
+ // [NOCODE], inferimos el task_type del prefijo. Pensado para casos donde
33
+ // el planner emite task_type='sw' por defecto pero el title declara
34
+ // claramente que la HU no produce código. Devuelve null si no hay match.
35
+ const TITLE_PREFIX_TO_TYPE = {
36
+ SPIKE: "spike",
37
+ RESEARCH: "research",
38
+ DOC: "doc",
39
+ NOCODE: "no-code",
40
+ "NO-CODE": "no-code",
41
+ };
42
+
43
+ export function inferTaskTypeFromTitle(title) {
44
+ if (typeof title !== "string") return null;
45
+ const m = title.match(/^\s*\[([A-Z][A-Z-]+)\]/);
46
+ if (!m) return null;
47
+ return TITLE_PREFIX_TO_TYPE[m[1].toUpperCase()] || null;
48
+ }
49
+
50
+ // KJC-TSK-0400: dado un HU del plan, devuelve el task_type efectivo:
51
+ // explícito en story.task_type si es válido, si no inferido del title.
52
+ // Fallback 'sw' (conservador, mantiene gates activos cuando hay duda).
53
+ export function effectiveTaskType(story) {
54
+ if (!story || typeof story !== "object") return "sw";
55
+ if (VALID_TASK_TYPES.has(story.task_type)) return story.task_type;
56
+ return inferTaskTypeFromTitle(story.title) || "sw";
57
+ }
58
+
22
59
  const FALLBACK_TYPE = "sw";
23
60
 
24
61
  /**
@@ -24,7 +24,7 @@ export function deriveProjectName(originalTask) {
24
24
  ]);
25
25
  const words = originalTask
26
26
  .toLowerCase()
27
- .replace(/[^\p{L}\p{N}\s-]/gu, " ")
27
+ .replaceAll(/[^\p{L}\p{N}\s-]/gu, " ")
28
28
  .split(/\s+/)
29
29
  .filter(w => w && !STOPWORDS.has(w));
30
30
  const meaningful = words.slice(0, 6);
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { createAgent } from "../agents/index.js";
9
9
  import { extractFirstJson } from "../utils/json-extract.js";
10
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
10
11
 
11
12
  const SUBAGENT_PREAMBLE = [
12
13
  "IMPORTANT: You are running as a Karajan sub-agent.",
@@ -72,7 +73,11 @@ export async function refineHuWithContext(hu, completedHus, config, logger) {
72
73
  const agent = createAgent(provider, config, logger);
73
74
  let result;
74
75
  try {
75
- result = await agent.runTask({ prompt, role: "hu-reviewer" });
76
+ // KJC-TSK-0413 step C
77
+ result = await withBrainRecovery({
78
+ agent, taskArgs: { prompt, role: "hu-reviewer" },
79
+ role: "lazy-planner", provider, logger,
80
+ });
76
81
  } catch (err) {
77
82
  logger.warn(`Lazy planner: refinement failed for HU ${hu.id}: ${err.message}`);
78
83
  return hu;
@@ -0,0 +1,108 @@
1
+ // KJC-TSK-0405: dado un HU con complexity score, recomienda coder_model y
2
+ // reviewer_model. El reviewer por defecto usa el provider alternativo al
3
+ // coder (cross-provider review) para que dos cabezas distintas miren el
4
+ // código y se detecten más patrones.
5
+
6
+ const LEVELS = ["trivial", "simple", "medium", "complex"];
7
+
8
+ // Mapping complexity score numérico → nivel categórico.
9
+ // 1 → trivial, 2 → simple, 3/4 → medium, 5+ → complex.
10
+ const SCORE_TO_LEVEL = {
11
+ 1: "trivial",
12
+ 2: "simple",
13
+ 3: "medium",
14
+ 4: "medium",
15
+ 5: "complex",
16
+ };
17
+
18
+ // Defaults por provider — alineados con utils/model-selector.js DEFAULTS.
19
+ // opencode: usa "coder" como alias razonable; el usuario suele definir
20
+ // gemma-local/coder, ollama/coder o similar en opencode.json. Override
21
+ // per-tier vía config.model_routing.by_provider.opencode si quieres
22
+ // rutear distinto por complexity (ej. fast para trivial, coder para
23
+ // complex). aider: null porque aider envuelve a otros providers — no
24
+ // tiene "modelos propios". Si el usuario lo elige, debe configurarlo
25
+ // explícito en config.model_routing.by_provider.aider o vía override
26
+ // del modal por HU.
27
+ const DEFAULT_TIERS = {
28
+ claude: { trivial: "haiku", simple: "haiku", medium: "sonnet", complex: "opus" },
29
+ codex: { trivial: "o4-mini", simple: "o4-mini", medium: "o4-mini", complex: "o3" },
30
+ gemini: { trivial: "gemini-2.0-flash", simple: "gemini-2.0-flash", medium: "gemini-2.5-pro", complex: "gemini-2.5-pro" },
31
+ opencode: { trivial: "coder", simple: "coder", medium: "coder", complex: "coder" },
32
+ aider: { trivial: null, simple: null, medium: null, complex: null },
33
+ };
34
+
35
+ // Cross-provider reviewer default: para cada coder provider, qué provider
36
+ // usa el reviewer por defecto. Dos cabezas distintas miran el código.
37
+ // opencode → claude: pattern "local barato escribe + cloud revisa".
38
+ // aider → claude: aider suele correr en un model pequeño, claude verifica.
39
+ const CROSS_PROVIDER_REVIEWER = {
40
+ claude: "codex",
41
+ codex: "claude",
42
+ gemini: "claude",
43
+ opencode: "claude",
44
+ aider: "claude",
45
+ };
46
+
47
+ // KJC-TSK-0405 step 2: heurística para inferir complexity desde
48
+ // task_type cuando el planner no emite un score explícito. Permite
49
+ // que el model-router asigne modelos razonables sin un triage explícito.
50
+ const TASK_TYPE_COMPLEXITY = {
51
+ sw: "medium",
52
+ refactor: "medium",
53
+ "add-tests": "simple",
54
+ infra: "trivial",
55
+ doc: "trivial",
56
+ spike: "complex",
57
+ research: "complex",
58
+ audit: "complex",
59
+ analysis: "medium",
60
+ "no-code": "trivial",
61
+ };
62
+
63
+ export function complexityFromTaskType(taskType) {
64
+ return TASK_TYPE_COMPLEXITY[taskType] || "medium";
65
+ }
66
+
67
+ export function complexityToLevel(complexity) {
68
+ if (typeof complexity === "string" && LEVELS.includes(complexity)) return complexity;
69
+ if (typeof complexity === "number" && SCORE_TO_LEVEL[complexity]) return SCORE_TO_LEVEL[complexity];
70
+ return "medium";
71
+ }
72
+
73
+ /**
74
+ * @param {object} args
75
+ * @param {string|number} args.complexity - "trivial" | "simple" | "medium" | "complex" | score 1-5
76
+ * @param {string} args.coderProvider - "claude" | "codex" | "gemini"
77
+ * @param {object} args.config - karajan config (lee model_routing si existe)
78
+ * @returns {{ coder_model: string|null, coder_provider: string, reviewer_model: string|null, reviewer_provider: string }}
79
+ */
80
+ export function recommendModelsForHu({ complexity, coderProvider, config = {} }) {
81
+ const level = complexityToLevel(complexity);
82
+ const routing = config.model_routing || {};
83
+ const fixed = routing.fixed || {};
84
+
85
+ // Merge tiers: defaults + user overrides per provider.
86
+ const tiers = { ...DEFAULT_TIERS };
87
+ if (routing.by_provider) {
88
+ for (const [prov, levels] of Object.entries(routing.by_provider)) {
89
+ tiers[prov] = { ...(tiers[prov] || {}), ...levels };
90
+ }
91
+ }
92
+
93
+ const coderTier = tiers[coderProvider];
94
+ const coderAuto = coderTier ? (coderTier[level] || null) : null;
95
+ const coder_model = fixed.coder || coderAuto;
96
+
97
+ const reviewer_provider = CROSS_PROVIDER_REVIEWER[coderProvider] || "claude";
98
+ const reviewerTier = tiers[reviewer_provider];
99
+ const reviewerAuto = reviewerTier ? (reviewerTier[level] || null) : null;
100
+ const reviewer_model = fixed.reviewer || reviewerAuto;
101
+
102
+ return {
103
+ coder_model,
104
+ coder_provider: coderProvider,
105
+ reviewer_model,
106
+ reviewer_provider,
107
+ };
108
+ }
@@ -7,6 +7,7 @@
7
7
  import { createAgent } from "../agents/index.js";
8
8
  import { extractFirstJson } from "../utils/json-extract.js";
9
9
  import { HEURISTIC_DESCRIPTIONS } from "./splitting-detector.js";
10
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
10
11
 
11
12
  const SUBAGENT_PREAMBLE = [
12
13
  "IMPORTANT: You are running as a Karajan sub-agent.",
@@ -97,7 +98,11 @@ export async function generateSplitProposal(hu, heuristic, config, logger) {
97
98
 
98
99
  let result;
99
100
  try {
100
- result = await agent.runTask({ prompt, role: "hu-splitter" });
101
+ // KJC-TSK-0413 step C
102
+ result = await withBrainRecovery({
103
+ agent, taskArgs: { prompt, role: "hu-splitter" },
104
+ role: "hu-splitter", provider, logger,
105
+ });
101
106
  } catch (err) {
102
107
  logger.warn(`HU split generation threw: ${err.message}`);
103
108
  return null;