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
@@ -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) {
@@ -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
  /**
@@ -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).replace(/[^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
  /**
@@ -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;
@@ -189,23 +189,33 @@ function applyHuFilterIfRequested({ huBatch, flags, eventBase, emitter, logger }
189
189
  logger.warn(`--hu was empty after parsing — falling back to running every certified HU`);
190
190
  return;
191
191
  }
192
- const requestedSet = new Set(requested);
193
- const found = huBatch.stories.filter((s) => requestedSet.has(s.id));
194
- const missing = requested.filter((id) => !huBatch.stories.some((s) => s.id === id));
195
- if (missing.length > 0) {
196
- logger.warn(`--hu: ignored ${missing.length} unknown HU id(s): ${missing.join(", ")}`);
192
+ // Humanización IDs: resolver cada ref a un hu.id largo aceptando 3 formas:
193
+ // 1. `hu_<planId>_<NNN>` (id largo, exacto)
194
+ // 2. `INFRA-001` (short_id que el planner asignó)
195
+ // 3. `001` (sufijo numérico — busca un hu.id que termine en `_001`)
196
+ // El resolver es case-insensitive para short_id y sufijo.
197
+ const resolvedIds = new Set();
198
+ const unresolved = [];
199
+ for (const ref of requested) {
200
+ const id = resolveHuRef(ref, huBatch.stories);
201
+ if (id) resolvedIds.add(id);
202
+ else unresolved.push(ref);
203
+ }
204
+ const found = huBatch.stories.filter((s) => resolvedIds.has(s.id));
205
+ if (unresolved.length > 0) {
206
+ logger.warn(`--hu: ignored ${unresolved.length} unknown HU ref(s): ${unresolved.join(", ")}`);
197
207
  }
198
208
  if (found.length === 0) {
199
- throw new Error(`--hu: none of the requested HU id(s) exist in plan ${flags.plan}: ${requested.join(", ")}`);
209
+ throw new Error(`--hu: none of the requested HU ref(s) exist in plan ${flags.plan}: ${requested.join(", ")}`);
200
210
  }
201
211
  for (const story of huBatch.stories) {
202
- if (!requestedSet.has(story.id)) story.status = "done";
212
+ if (!resolvedIds.has(story.id)) story.status = "done";
203
213
  }
204
214
  huBatch.certified = found.length;
205
215
  logger.info(`Plan ${flags.plan}: --hu filter active — ${found.length}/${huBatch.total} HU(s) will run (${found.map((s) => s.id).join(", ")})`);
206
216
  emitProgress(emitter, makeEvent("plan:hu-filter", { ...eventBase, stage: "plan" }, {
207
217
  message: `--hu filter: ${found.length} HU(s) selected, ${huBatch.total - found.length} skipped`,
208
- detail: { planId: flags.plan, requested, ignored: missing, willRun: found.map((s) => s.id) }
218
+ detail: { planId: flags.plan, requested, ignored: unresolved, willRun: found.map((s) => s.id) }
209
219
  }));
210
220
  }
211
221
 
@@ -246,6 +256,35 @@ function wirePlanCallbacks({ session, loadedPlan, projectDir, syncResultsToPlan,
246
256
  });
247
257
  }
248
258
 
259
+ /**
260
+ * Resuelve una referencia humana a HU al `id` canónico largo. Acepta:
261
+ * - id largo exacto: `hu_<planId>_<NNN>` → match directo.
262
+ * - short_id: `INFRA-001`, `AUTH-SIGNUP` → match case-insensitive contra hu.short_id.
263
+ * - sufijo numérico: `001` → match contra hu.id terminado en `_001`.
264
+ * Devuelve el hu.id largo o null si no resuelve.
265
+ *
266
+ * @param {string} ref
267
+ * @param {Array<{id: string, short_id?: string|null}>} hus
268
+ * @returns {string|null}
269
+ */
270
+ function resolveHuRef(ref, hus) {
271
+ if (!ref || !Array.isArray(hus)) return null;
272
+ // Match exacto contra id largo (camino caliente).
273
+ const exact = hus.find((s) => s.id === ref);
274
+ if (exact) return exact.id;
275
+ // Match case-insensitive contra short_id.
276
+ const lower = ref.toLowerCase();
277
+ const byShort = hus.find((s) => s.short_id && String(s.short_id).toLowerCase() === lower);
278
+ if (byShort) return byShort.id;
279
+ // Match por sufijo numérico (`001` → cualquier hu cuyo id termine en `_001`).
280
+ if (/^\d+$/.test(ref)) {
281
+ const padded = ref.padStart(3, "0");
282
+ const bySuffix = hus.find((s) => s.id.endsWith(`_${padded}`));
283
+ if (bySuffix) return bySuffix.id;
284
+ }
285
+ return null;
286
+ }
287
+
249
288
  /**
250
289
  * Build a single plannedTask string for the non-HU / fallback
251
290
  * iteration path. The HU sub-pipeline doesn't read this; only the
@@ -31,6 +31,7 @@ import { classifyFailure, createFailureTracker } from "../repair/repair-detector
31
31
  import { runRepair } from "../repair/repair-runner.js";
32
32
  import { writeHistoryRecord } from "./post-loop.js";
33
33
  import { setReviewerFeedback } from "../../session/mutators.js";
34
+ import { createHuSnapshot } from "../../git/hu-snapshot.js";
34
35
 
35
36
  /**
36
37
  * @param {object} args
@@ -91,19 +92,44 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
91
92
  ctx.brainCtx.feedbackQueue = fresh.feedbackQueue;
92
93
  ctx.brainCtx.verificationTracker = fresh.verificationTracker;
93
94
  }
94
- // Apply per-HU policies based on task_type (infra skips reviewer/sonar/tdd)
95
- const { applyPolicies } = await import("../../guards/policy-resolver.js");
96
- const huPolicies = applyPolicies({ taskType: story.task_type, policies: ctx.config.policies });
95
+ // Apply per-HU policies based on task_type (infra skips reviewer/sonar/tdd).
96
+ // KJC-TSK-0400: `effectiveTaskType` mira primero story.task_type y, si
97
+ // no es válido, infiere del prefijo del title ([SPIKE], [DOC]…). Así
98
+ // un HU con title "[SPIKE] Decidir auth" entra automáticamente a las
99
+ // policies de spike (sin Sonar/TDD/tests).
100
+ const { applyPolicies, effectiveTaskType } = await import("../../guards/policy-resolver.js");
101
+ const resolvedTaskType = effectiveTaskType(story);
102
+ if (resolvedTaskType !== story.task_type) {
103
+ logger.info(`HU ${story.id}: task_type inferido del title → ${resolvedTaskType} (era ${story.task_type || "null"})`);
104
+ }
105
+ const huPolicies = applyPolicies({ taskType: resolvedTaskType, policies: ctx.config.policies });
97
106
  const savedFlags = { ...ctx.pipelineFlags };
98
107
  if (!huPolicies.reviewer) ctx.pipelineFlags.reviewerEnabled = false;
99
108
  if (!huPolicies.tdd) ctx.config.development = { ...ctx.config.development, methodology: "standard", require_test_changes: false };
100
109
  if (!huPolicies.sonar) ctx.config.sonarqube = { ...ctx.config.sonarqube, enabled: false };
101
110
  if (!huPolicies.testsRequired) ctx.pipelineFlags.testerEnabled = false;
102
- logger.info(`HU ${story.id} (${story.task_type}): policies → reviewer=${huPolicies.reviewer}, tdd=${huPolicies.tdd}, sonar=${huPolicies.sonar}, tests=${huPolicies.testsRequired}`);
111
+ logger.info(`HU ${story.id} (${resolvedTaskType}): policies → reviewer=${huPolicies.reviewer}, tdd=${huPolicies.tdd}, sonar=${huPolicies.sonar}, tests=${huPolicies.testsRequired}`);
103
112
 
104
113
  const branchName = await prepareHuBranch({ story, huBranches, config: ctx.config, logger });
105
114
  const projectDir = ctx.config.projectDir || process.cwd();
106
115
 
116
+ // KJC-TSK-0408 step 2: snapshot del workspace ANTES de invocar al
117
+ // coder. Si el usuario hace Undo después, restaura este SHA. El
118
+ // ref se crea sobre el HEAD ya checkouteado a la rama de la HU,
119
+ // así que apunta al estado limpio sobre el que el coder empieza.
120
+ // Idempotente — si la HU ya tenía snapshot, se sobreescribe.
121
+ try {
122
+ const snap = await createHuSnapshot({ projectDir, huId: story.id });
123
+ if (snap.ok) {
124
+ story.outcome = { ...(story.outcome || {}), snapshot_sha: snap.sha, snapshot_ref: snap.ref };
125
+ logger.info(`HU ${story.id}: snapshot ${snap.sha.slice(0, 7)} (ref ${snap.ref})`);
126
+ } else {
127
+ logger.warn(`HU ${story.id}: snapshot falló (non-blocking): ${snap.error}`);
128
+ }
129
+ } catch (err) {
130
+ logger.warn(`HU ${story.id}: snapshot threw (non-blocking): ${err.message}`);
131
+ }
132
+
107
133
  // If HU has acceptance_tests, Brain runs them as the gate instead of
108
134
  // the standard reviewer/tester pipeline. This is the radical fix:
109
135
  // concrete executable tests replace subjective reviewer opinions.