karajan-code 2.14.2 → 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 +3 -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/checks/project-checks.js +245 -0
- package/src/checks/runner.js +29 -0
- package/src/checks/tokens.js +24 -1
- package/src/checks/types.js +10 -0
- package/src/cli/register-pipeline.js +2 -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/doctor.js +18 -5
- 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/orchestrator/preflight-checks.js +5 -0
- 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
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.
|
|
@@ -29,6 +29,7 @@ import { getPortChecks } from "../checks/ports.js";
|
|
|
29
29
|
import { getTokenChecks } from "../checks/tokens.js";
|
|
30
30
|
import { getMcpHealthChecks } from "../checks/mcp-health.js";
|
|
31
31
|
import { getSkillsChecks } from "../checks/skills.js";
|
|
32
|
+
import { getProjectChecks } from "../checks/project-checks.js";
|
|
32
33
|
import { resolveTestHarness } from "../config/test-harness.js";
|
|
33
34
|
|
|
34
35
|
function parseJsonSafe(text) {
|
|
@@ -369,6 +370,10 @@ async function runExtendedPreflight({ config, result, emitter, eventBase, logger
|
|
|
369
370
|
...getTokenChecks(config),
|
|
370
371
|
...getMcpHealthChecks(),
|
|
371
372
|
...getSkillsChecks(),
|
|
373
|
+
// KJC-TSK-0393: project-aware checks (signal detection, write perms,
|
|
374
|
+
// tools per signal, .env consistency, gh remote access). Saltables con
|
|
375
|
+
// flag --no-project-checks en kj run.
|
|
376
|
+
...(config?.flags?.noProjectChecks ? [] : getProjectChecks({ projectDir: config?.projectDir || process.cwd() })),
|
|
372
377
|
];
|
|
373
378
|
let report;
|
|
374
379
|
try {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { isPlanV2 } from "./plan-schema.js";
|
|
8
|
-
import { updateHuStatus, computePlanOutcome, setPlanOutcome } from "./plan-hu-ops.js";
|
|
8
|
+
import { updateHuStatus, updateHu, computePlanOutcome, setPlanOutcome } from "./plan-hu-ops.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Convert a v2 plan's HUs into the stageResults.huReviewer format
|
|
@@ -63,9 +63,18 @@ export function planToHuBatch(plan) {
|
|
|
63
63
|
export function syncResultsToPlan(plan, subPipelineResult) {
|
|
64
64
|
if (!isPlanV2(plan)) return;
|
|
65
65
|
|
|
66
|
+
// KJC-TSK-0403: status/result ortogonal. HU fallida queda en status=pending
|
|
67
|
+
// con result=fail (no status=failed) para que el usuario pueda relanzarla
|
|
68
|
+
// con el contexto del fallo previo. El plan se considera failed si CUALQUIER
|
|
69
|
+
// HU tiene result=fail.
|
|
66
70
|
for (const r of subPipelineResult.results || []) {
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
if (r.approved) {
|
|
72
|
+
updateHuStatus(plan, r.huId, "done");
|
|
73
|
+
updateHu(plan, r.huId, { result: "pass" });
|
|
74
|
+
} else {
|
|
75
|
+
updateHuStatus(plan, r.huId, "pending");
|
|
76
|
+
updateHu(plan, r.huId, { result: "fail" });
|
|
77
|
+
}
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
for (const blockedId of subPipelineResult.blockedIds || []) {
|
|
@@ -73,7 +82,7 @@ export function syncResultsToPlan(plan, subPipelineResult) {
|
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
const allDone = plan.hus.every(h => h.status === "done");
|
|
76
|
-
const anyFailed = plan.hus.some(h => h.
|
|
85
|
+
const anyFailed = plan.hus.some(h => h.result === "fail");
|
|
77
86
|
plan.status = allDone ? "done" : anyFailed ? "failed" : "running";
|
|
78
87
|
plan.updatedAt = new Date().toISOString();
|
|
79
88
|
|
package/src/plan/plan-fixer.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { extractFirstJson } from "../utils/json-extract.js";
|
|
13
13
|
import { addHu, removeHu } from "./plan-hu-ops.js";
|
|
14
14
|
import { normaliseAcceptanceTests } from "./plan-schema.js";
|
|
15
|
+
import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
15
16
|
|
|
16
17
|
export function buildFixerPrompt({ task, hus, findings }) {
|
|
17
18
|
const huSummary = hus.map((h) => {
|
|
@@ -90,14 +91,18 @@ function normalisePatch(raw) {
|
|
|
90
91
|
return out;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
export async function applyReviewerFeedback({ agent, task, hus, findings, onOutput, silenceTimeoutMs, timeoutMs }) {
|
|
94
|
+
export async function applyReviewerFeedback({ agent, task, hus, findings, onOutput, silenceTimeoutMs, timeoutMs, emitter, eventBase, logger }) {
|
|
94
95
|
const prompt = buildFixerPrompt({ task, hus, findings });
|
|
95
96
|
const runArgs = { prompt, role: "planner" };
|
|
96
97
|
if (onOutput) runArgs.onOutput = onOutput;
|
|
97
98
|
if (silenceTimeoutMs) runArgs.silenceTimeoutMs = silenceTimeoutMs;
|
|
98
99
|
if (timeoutMs) runArgs.timeoutMs = timeoutMs;
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
// KJC-TSK-0413: Brain Recovery — fix loop ya no muere silencioso.
|
|
101
|
+
const result = await withBrainRecovery({
|
|
102
|
+
agent, taskArgs: runArgs, role: "plan-fixer",
|
|
103
|
+
emitter, eventBase, logger,
|
|
104
|
+
});
|
|
105
|
+
if (!result.ok) return { ok: false, error: result.error || `fixer agent failed (${result.recovery?.class || "unknown"})`, recovery: result.recovery, action: result.action };
|
|
101
106
|
const parsed = extractFirstJson(result.output || "");
|
|
102
107
|
if (!parsed || typeof parsed !== "object") {
|
|
103
108
|
return { ok: false, error: "fixer returned no JSON object" };
|
|
@@ -110,16 +115,43 @@ export function applyFixerPatch(plan, patch) {
|
|
|
110
115
|
for (const huId of patch.deletions || []) {
|
|
111
116
|
if (removeHu(plan, huId)) counts.deleted += 1;
|
|
112
117
|
}
|
|
118
|
+
// KJC-BUG-0053: las HUs añadidas por el fixer dejaban `short_id` y
|
|
119
|
+
// `blocked_by` vacíos porque addHu no recibía esos campos. Resultado:
|
|
120
|
+
// HUs huérfanas con id largo críptico y sin orden de ejecución.
|
|
121
|
+
//
|
|
122
|
+
// Fix: (1) pasar `short_id: a.id` (el id simbólico que el fixer LLM
|
|
123
|
+
// asigna a cada addition). (2) resolver `a.dependencies` simbólicas
|
|
124
|
+
// → hu.id largos contra el plan ya existente + el mapa de símbolos
|
|
125
|
+
// recién añadidos. La resolución necesita 2 pasadas (primera crea
|
|
126
|
+
// las HUs y registra el mapping, segunda asigna deps) para que
|
|
127
|
+
// additions que se referencian entre sí funcionen.
|
|
128
|
+
const symbolicToHuId = new Map();
|
|
129
|
+
for (const hu of plan.hus) {
|
|
130
|
+
if (hu.short_id) symbolicToHuId.set(hu.short_id, hu.id);
|
|
131
|
+
}
|
|
132
|
+
const addedHus = []; // {hu, depsSymbolic}
|
|
113
133
|
for (const a of patch.additions || []) {
|
|
114
|
-
|
|
134
|
+
const symbolicId = typeof a.id === "string" && a.id.trim() ? a.id.trim() : null;
|
|
135
|
+
const hu = addHu(plan, {
|
|
115
136
|
title: (a.description || "").slice(0, 80),
|
|
116
137
|
scope: a.description,
|
|
138
|
+
short_id: symbolicId,
|
|
117
139
|
spec_section: a.spec_section,
|
|
118
140
|
acceptance_tests: a.acceptance_tests,
|
|
119
141
|
reuse: a.reuse || [],
|
|
120
142
|
});
|
|
143
|
+
if (symbolicId) symbolicToHuId.set(symbolicId, hu.id);
|
|
144
|
+
const depsSymbolic = Array.isArray(a.dependencies) ? a.dependencies : [];
|
|
145
|
+
addedHus.push({ hu, depsSymbolic });
|
|
121
146
|
counts.added += 1;
|
|
122
147
|
}
|
|
148
|
+
// Pasada 2: resolver deps simbólicas de las additions a ids largos.
|
|
149
|
+
for (const { hu, depsSymbolic } of addedHus) {
|
|
150
|
+
const resolved = depsSymbolic
|
|
151
|
+
.map((s) => typeof s === "string" ? symbolicToHuId.get(s.trim()) : null)
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
if (resolved.length > 0) hu.blocked_by = resolved;
|
|
154
|
+
}
|
|
123
155
|
const knownIds = new Set(plan.hus.map(h => h.id));
|
|
124
156
|
for (const { huId, on } of patch.deps_to_add || []) {
|
|
125
157
|
if (!knownIds.has(huId) || !knownIds.has(on)) continue;
|