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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/brain/agent-error-classifier.js +101 -0
- package/src/brain/standby-scheduler.js +92 -0
- package/src/brain/standby-store.js +129 -0
- package/src/brain/with-brain-recovery.js +178 -0
- package/src/cli/register-plan.js +29 -0
- package/src/cli/register-standby.js +26 -0
- package/src/cli.js +2 -0
- package/src/commands/architect.js +6 -2
- package/src/commands/discover.js +6 -2
- package/src/commands/init.js +47 -0
- package/src/commands/plan/_shared.js +19 -4
- package/src/commands/plan/fix.js +99 -0
- package/src/commands/plan/generate.js +112 -27
- package/src/commands/plan/index.js +2 -0
- package/src/commands/plan/migrate-result.js +41 -0
- package/src/commands/plan.js +2 -0
- package/src/commands/researcher.js +6 -2
- package/src/commands/run.js +33 -1
- package/src/commands/standby.js +114 -0
- package/src/commands/triage.js +6 -2
- package/src/config/schema.js +40 -0
- package/src/git/hu-snapshot.js +105 -0
- package/src/guards/policy-resolver.js +38 -1
- package/src/hu/lazy-planner.js +6 -1
- package/src/hu/model-router.js +108 -0
- package/src/hu/splitting-generator.js +6 -1
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
- package/src/orchestrator/drivers/run-hu-batch.js +30 -4
- package/src/plan/plan-executor.js +13 -4
- package/src/plan/plan-fixer.js +36 -4
- package/src/plan/plan-hu-ops.js +24 -1
- package/src/plan/plan-id.js +20 -0
- package/src/plan/plan-migrate-result.js +24 -0
- package/src/plan/plan-reviewer.js +40 -3
- package/src/plan/plan-schema.js +128 -1
- package/src/plan/plan-store.js +63 -8
- package/src/plan/plan-structural-pass.js +122 -0
- package/src/plan/plan-validation.js +82 -0
- package/src/plan/tests-synthesizer.js +8 -3
- package/src/prompts/planner.js +27 -1
- package/src/roles/agent-role.js +18 -3
- package/src/roles/planner-role.js +10 -3
- package/src/roles/solomon-role.js +10 -4
- package/src/utils/cli-progress.js +58 -1
- package/src/utils/garbage-collector.js +85 -2
- 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
|
+
}
|
package/src/commands/triage.js
CHANGED
|
@@ -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
|
|
51
|
-
|
|
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) {
|
package/src/config/schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/hu/lazy-planner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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 (!
|
|
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:
|
|
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
|
-
|
|
96
|
-
|
|
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} (${
|
|
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.
|