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.
Files changed (55) hide show
  1. package/README.md +3 -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/checks/project-checks.js +245 -0
  8. package/src/checks/runner.js +29 -0
  9. package/src/checks/tokens.js +24 -1
  10. package/src/checks/types.js +10 -0
  11. package/src/cli/register-pipeline.js +2 -0
  12. package/src/cli/register-plan.js +29 -0
  13. package/src/cli/register-standby.js +26 -0
  14. package/src/cli.js +2 -0
  15. package/src/commands/architect.js +6 -2
  16. package/src/commands/discover.js +6 -2
  17. package/src/commands/doctor.js +18 -5
  18. package/src/commands/init.js +47 -0
  19. package/src/commands/plan/_shared.js +19 -4
  20. package/src/commands/plan/fix.js +99 -0
  21. package/src/commands/plan/generate.js +112 -27
  22. package/src/commands/plan/index.js +2 -0
  23. package/src/commands/plan/migrate-result.js +41 -0
  24. package/src/commands/plan.js +2 -0
  25. package/src/commands/researcher.js +6 -2
  26. package/src/commands/run.js +33 -1
  27. package/src/commands/standby.js +114 -0
  28. package/src/commands/triage.js +6 -2
  29. package/src/config/schema.js +40 -0
  30. package/src/git/hu-snapshot.js +105 -0
  31. package/src/guards/policy-resolver.js +38 -1
  32. package/src/hu/lazy-planner.js +6 -1
  33. package/src/hu/model-router.js +108 -0
  34. package/src/hu/splitting-generator.js +6 -1
  35. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
  36. package/src/orchestrator/drivers/run-hu-batch.js +30 -4
  37. package/src/orchestrator/preflight-checks.js +5 -0
  38. package/src/plan/plan-executor.js +13 -4
  39. package/src/plan/plan-fixer.js +36 -4
  40. package/src/plan/plan-hu-ops.js +24 -1
  41. package/src/plan/plan-id.js +20 -0
  42. package/src/plan/plan-migrate-result.js +24 -0
  43. package/src/plan/plan-reviewer.js +40 -3
  44. package/src/plan/plan-schema.js +128 -1
  45. package/src/plan/plan-store.js +63 -8
  46. package/src/plan/plan-structural-pass.js +122 -0
  47. package/src/plan/plan-validation.js +82 -0
  48. package/src/plan/tests-synthesizer.js +8 -3
  49. package/src/prompts/planner.js +27 -1
  50. package/src/roles/agent-role.js +18 -3
  51. package/src/roles/planner-role.js +10 -3
  52. package/src/roles/solomon-role.js +10 -4
  53. package/src/utils/cli-progress.js +58 -1
  54. package/src/utils/garbage-collector.js +85 -2
  55. package/src/utils/run-registry.js +115 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Project-aware preflight checks (KJC-TSK-0393).
3
+ *
4
+ * `kj doctor` (global) y el preflight estándar verifican el ENTORNO de Karajan
5
+ * (CLIs instaladas, node, dirs ~/.karajan/, sonar). Pero **nada** validaba que
6
+ * el PROYECTO actual tuviera lo que necesita: tools detectables por signals
7
+ * (docker si hay Dockerfile, firebase si hay firebase.json, etc.), permisos
8
+ * write sobre projectDir, configuración inicializada, .env consistente con
9
+ * .env.example.
10
+ *
11
+ * Este módulo registra checks dinámicos basados en signals del proyecto. Se
12
+ * integra en preflight-checks.js como "fase project-aware" tras la fase global.
13
+ * También expone `kj doctor --project` para ejecutar solo esta fase manualmente.
14
+ *
15
+ * Filosofía: si un check falla, el mensaje incluye el comando EJECUTABLE para
16
+ * resolverlo (no "Run X or Y"), y siempre que sea posible se marca como
17
+ * `degradable` (KJC-BUG-0049) para no abortar runs por features opcionales.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { runCommand } from "../utils/process.js";
23
+ import { checkBinary } from "../utils/agent-detect.js";
24
+ import { getInstallCommand } from "../utils/os-detect.js";
25
+ import { STRATEGY } from "./types.js";
26
+
27
+ /**
28
+ * Detect signals del proyecto. Devuelve un set de tags con qué hay en el
29
+ * projectDir. Pure-function, solo I/O de filesystem síncrono (rápido).
30
+ *
31
+ * @param {string} projectDir
32
+ * @returns {Set<string>}
33
+ */
34
+ export function detectProjectSignals(projectDir) {
35
+ const signals = new Set();
36
+ if (!projectDir || !fs.existsSync(projectDir)) return signals;
37
+ const has = (rel) => fs.existsSync(path.join(projectDir, rel));
38
+ if (has("package.json")) signals.add("node");
39
+ if (has("Dockerfile") || has("docker-compose.yml") || has("compose.yml")) signals.add("docker");
40
+ if (has("firebase.json") || has(".firebaserc")) signals.add("firebase");
41
+ if (has("pyproject.toml") || has("requirements.txt") || has("setup.py")) signals.add("python");
42
+ if (has("Cargo.toml")) signals.add("rust");
43
+ if (has("go.mod")) signals.add("go");
44
+ if (has(".env.example") || has(".env.sample") || has(".env.template")) signals.add("env-example");
45
+ if (has(".env")) signals.add("env");
46
+ // Terraform / OpenTofu
47
+ try {
48
+ const entries = fs.readdirSync(projectDir);
49
+ if (entries.some((e) => e.endsWith(".tf") || e.endsWith(".tofu"))) signals.add("terraform");
50
+ } catch { /* unreadable dir */ }
51
+ return signals;
52
+ }
53
+
54
+ /**
55
+ * Check de tool específico del signal. Si el signal está presente pero la tool
56
+ * no, emite WARN con install command ejecutable.
57
+ */
58
+ function createToolCheck({ signal, tool, label, signals }) {
59
+ return {
60
+ name: `project:tool:${tool}`,
61
+ label,
62
+ strategy: STRATEGY.MANUAL,
63
+ applies: () => signals.has(signal),
64
+ async detect() {
65
+ const res = await checkBinary(tool);
66
+ if (res.ok) {
67
+ return { ok: true, severity: "info", detail: `${tool} ${res.version} — required by ${signal} signal` };
68
+ }
69
+ const installCmd = getInstallCommand(tool) || `<install ${tool} for your OS>`;
70
+ return {
71
+ ok: false,
72
+ severity: "warn",
73
+ detail: `${tool} no instalada pero el proyecto tiene signal '${signal}' (puede no necesitarla para esta HU, pero la mayoría sí)`,
74
+ fix: installCmd,
75
+ };
76
+ },
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Permisos write check. Critical: si projectDir, .kj/ o .karajan/ no son
82
+ * escribibles, el pipeline va a fallar muy tarde con errores oscuros.
83
+ */
84
+ function createWritePermsCheck({ projectDir }) {
85
+ return {
86
+ name: "project:write-perms",
87
+ label: "Write permissions (projectDir + .kj/ + .karajan/)",
88
+ strategy: STRATEGY.MANUAL,
89
+ async detect() {
90
+ const targets = [
91
+ projectDir,
92
+ path.join(projectDir, ".kj"),
93
+ path.join(projectDir, ".karajan"),
94
+ ];
95
+ const failed = [];
96
+ for (const target of targets) {
97
+ try {
98
+ if (!fs.existsSync(target)) continue;
99
+ fs.accessSync(target, fs.constants.W_OK);
100
+ } catch {
101
+ failed.push(target);
102
+ }
103
+ }
104
+ if (failed.length === 0) {
105
+ return { ok: true, severity: "info", detail: "Karajan puede escribir en projectDir, .kj/ y .karajan/" };
106
+ }
107
+ return {
108
+ ok: false,
109
+ severity: "fail",
110
+ detail: `Sin permisos de escritura en: ${failed.join(", ")}`,
111
+ fix: `chmod -R u+w ${failed.join(" ")}`,
112
+ };
113
+ },
114
+ };
115
+ }
116
+
117
+ /**
118
+ * .env consistency check: si existe .env.example pero NO .env, o si las vars
119
+ * que .env.example declara faltan en .env, emite WARN con la lista.
120
+ */
121
+ function createEnvConsistencyCheck({ projectDir, signals }) {
122
+ return {
123
+ name: "project:env-consistency",
124
+ label: ".env consistency (vs .env.example)",
125
+ strategy: STRATEGY.MANUAL,
126
+ applies: () => signals.has("env-example"),
127
+ async detect() {
128
+ const exampleFile = ["env.example", ".env.sample", ".env.template"]
129
+ .map((n) => path.join(projectDir, n.startsWith(".") ? n : `.${n}`))
130
+ .find((p) => fs.existsSync(p));
131
+ const realFile = path.join(projectDir, ".env");
132
+ const declaredVars = parseEnvKeys(exampleFile);
133
+ if (declaredVars.length === 0) {
134
+ return { ok: true, severity: "info", detail: ".env.example sin variables declaradas" };
135
+ }
136
+ const realVars = fs.existsSync(realFile) ? new Set(parseEnvKeys(realFile)) : new Set();
137
+ const missing = declaredVars.filter((k) => !realVars.has(k));
138
+ if (missing.length === 0) {
139
+ return { ok: true, severity: "info", detail: `.env tiene las ${declaredVars.length} variables declaradas en .env.example` };
140
+ }
141
+ return {
142
+ ok: false,
143
+ severity: "warn",
144
+ detail: `.env no declara ${missing.length} variable(s) que sí están en .env.example: ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "..." : ""}`,
145
+ fix: `cp ${path.basename(exampleFile)} .env && edit it`,
146
+ };
147
+ },
148
+ };
149
+ }
150
+
151
+ function parseEnvKeys(filePath) {
152
+ try {
153
+ const content = fs.readFileSync(filePath, "utf8");
154
+ return content
155
+ .split("\n")
156
+ .map((l) => l.trim())
157
+ .filter((l) => l && !l.startsWith("#") && l.includes("="))
158
+ .map((l) => l.split("=")[0].trim())
159
+ .filter(Boolean);
160
+ } catch { return []; }
161
+ }
162
+
163
+ /**
164
+ * gh remote-specific check: cuando auto_pr está activo, no basta con que `gh`
165
+ * esté autenticado en general (eso lo cubre token:gh ya). Hay que verificar
166
+ * que el usuario tiene push access AL REMOTE de este proyecto.
167
+ */
168
+ function createGhRemoteCheck({ projectDir }) {
169
+ return {
170
+ name: "project:gh-remote-access",
171
+ label: "GitHub push access (current remote)",
172
+ strategy: STRATEGY.MANUAL,
173
+ applies: (config) => config?.git?.auto_pr === true,
174
+ degradable: {
175
+ disables: ["git.auto_pr"],
176
+ warn: "auto_pr DESACTIVADO — no se pueden crear PRs al remote actual. Verifica permisos con `gh repo view`",
177
+ },
178
+ async detect() {
179
+ // Verifica que el remote responde a ls-remote (read access mínimo,
180
+ // proxy para push access — el push real solo se valida al pushear).
181
+ try {
182
+ const res = await runCommand("git", ["-C", projectDir, "ls-remote", "--heads", "origin"], { reject: false });
183
+ if (res.exitCode === 0) {
184
+ return { ok: true, severity: "info", detail: "Remote origin accesible vía git ls-remote" };
185
+ }
186
+ return {
187
+ ok: false,
188
+ severity: "warn",
189
+ detail: `git ls-remote origin falló (exit ${res.exitCode}): ${(res.stderr || "").slice(0, 120)}`,
190
+ fix: "Verifica el remote con `git remote -v` y la auth con `gh repo view`",
191
+ };
192
+ } catch (err) {
193
+ return { ok: false, severity: "warn", detail: `git ls-remote error: ${err.message}`, fix: "Verifica git remote y auth gh" };
194
+ }
195
+ },
196
+ };
197
+ }
198
+
199
+ /**
200
+ * `kj init` ran check: si no existe ~/.karajan/kj.config.yml NI .karajan/coder-rules.md,
201
+ * el usuario probablemente no ejecutó `kj init`. WARN con instrucción ejecutable.
202
+ */
203
+ function createKjInitCheck({ projectDir }) {
204
+ return {
205
+ name: "project:kj-init-ran",
206
+ label: "Karajan project initialized",
207
+ strategy: STRATEGY.MANUAL,
208
+ async detect() {
209
+ const projectKjFile = path.join(projectDir, ".karajan", "coder-rules.md");
210
+ const globalKjFile = path.join(process.env.HOME || "", ".karajan", "kj.config.yml");
211
+ if (fs.existsSync(projectKjFile) || fs.existsSync(globalKjFile)) {
212
+ return { ok: true, severity: "info", detail: "Karajan config presente (global o local del proyecto)" };
213
+ }
214
+ return {
215
+ ok: false,
216
+ severity: "warn",
217
+ detail: "No se encontró kj.config.yml global ni .karajan/coder-rules.md local — probablemente falta `kj init`",
218
+ fix: "kj init",
219
+ };
220
+ },
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Construye el set completo de project-aware checks basado en projectDir.
226
+ *
227
+ * @param {{ projectDir: string }} ctx
228
+ * @returns {Check[]}
229
+ */
230
+ export function getProjectChecks({ projectDir }) {
231
+ if (!projectDir) return [];
232
+ const signals = detectProjectSignals(projectDir);
233
+ return [
234
+ createKjInitCheck({ projectDir }),
235
+ createWritePermsCheck({ projectDir }),
236
+ createToolCheck({ signal: "docker", tool: "docker", label: "Docker (project signal)", signals }),
237
+ createToolCheck({ signal: "firebase", tool: "firebase", label: "firebase-cli (project signal)", signals }),
238
+ createToolCheck({ signal: "python", tool: "python3", label: "Python 3 (project signal)", signals }),
239
+ createToolCheck({ signal: "rust", tool: "cargo", label: "Cargo / Rust (project signal)", signals }),
240
+ createToolCheck({ signal: "go", tool: "go", label: "Go (project signal)", signals }),
241
+ createToolCheck({ signal: "terraform", tool: "terraform", label: "Terraform (project signal)", signals }),
242
+ createEnvConsistencyCheck({ projectDir, signals }),
243
+ createGhRemoteCheck({ projectDir }),
244
+ ];
245
+ }
@@ -98,6 +98,17 @@ export async function runChecks(checks, ctx, options = {}) {
98
98
  entry.runMs = Date.now() - start;
99
99
  entry.status = resolveStatusFromDetect(entry.check, result);
100
100
  entry.detail = result.detail;
101
+ // KJC-BUG-0049: si el check es degradable y falló (status FAIL/WARN tras
102
+ // resolveStatusFromDetect), aplicar disables al overrides y degradar a WARN.
103
+ // Así el preflight no aborta — la feature opcional se desactiva con aviso.
104
+ if (!result.ok && entry.check.degradable && (entry.status === STATUS.FAIL || entry.status === STATUS.WARN)) {
105
+ for (const dotPath of entry.check.degradable.disables || []) {
106
+ setDotPath(overrides, dotPath, false);
107
+ }
108
+ entry.status = STATUS.WARN;
109
+ entry.detail = `${result.detail} → ${entry.check.degradable.warn}`;
110
+ entry.degraded = true;
111
+ }
101
112
  } catch (err) {
102
113
  entry.runMs = Date.now() - start;
103
114
  if (err instanceof TimeoutError) {
@@ -227,6 +238,24 @@ function summarize(reports) {
227
238
  return summary;
228
239
  }
229
240
 
241
+ /**
242
+ * Set a dot-path on a target object, creating intermediate objects as needed.
243
+ * Used by the `degradable` check support (KJC-BUG-0049) to disable feature
244
+ * flags like `git.auto_pr` when their preflight check fails.
245
+ *
246
+ * @example setDotPath({}, "git.auto_pr", false) // → { git: { auto_pr: false } }
247
+ */
248
+ function setDotPath(target, dotPath, value) {
249
+ const parts = String(dotPath).split(".");
250
+ let cursor = target;
251
+ for (let i = 0; i < parts.length - 1; i++) {
252
+ const k = parts[i];
253
+ if (cursor[k] == null || typeof cursor[k] !== "object") cursor[k] = {};
254
+ cursor = cursor[k];
255
+ }
256
+ cursor[parts.at(-1)] = value;
257
+ }
258
+
230
259
  /**
231
260
  * Deep-merge (shallow for now, one level deep) remediation changes into overrides.
232
261
  * Remediation outputs like { hu_board: { port: 4007 } } should not overwrite a
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { checkBinary } from "../utils/agent-detect.js";
23
23
  import { getRequiredProviderEnvs, normalizeProvider } from "../utils/provider-env.js";
24
+ import { runCommand } from "../utils/process.js";
24
25
  import { STRATEGY } from "./types.js";
25
26
 
26
27
  /**
@@ -95,6 +96,13 @@ function createGhTokenCheck() {
95
96
  strategy: STRATEGY.PROMPT,
96
97
  applies: (config) => config?.git?.auto_pr === true,
97
98
  describe: "Run 'gh auth login' to authenticate with GitHub",
99
+ // KJC-BUG-0049: auto_pr es feature opcional. Si falta el token, no abortar
100
+ // el run — desactivar auto_pr y auto_push (sin push no hay PR posible) y
101
+ // continuar. El usuario verá WARN y el commit local sigue funcionando.
102
+ degradable: {
103
+ disables: ["git.auto_pr", "git.auto_push"],
104
+ warn: "auto_pr y auto_push DESACTIVADOS para este run (commit local sí funciona). Ejecuta `gh auth login` para reactivar.",
105
+ },
98
106
  async detect() {
99
107
  const envOk = !!process.env.GH_TOKEN || !!process.env.GITHUB_TOKEN;
100
108
  if (envOk) {
@@ -111,10 +119,25 @@ function createGhTokenCheck() {
111
119
  extra: { hasGh: false },
112
120
  };
113
121
  }
122
+ // KJC-BUG-0049 — el check anterior asumía que un usuario sin env var no
123
+ // tiene auth válida y abortaba el run. Pero `gh` puede estar autenticado
124
+ // por keyring/OAuth (caso default tras `gh auth login --web`) y operar
125
+ // sin env var. Si `gh auth status` devuelve exit 0 (logged in), el flujo
126
+ // de `auto_pr` funciona — Karajan ya no necesita `GH_TOKEN` en env.
127
+ try {
128
+ const auth = await runCommand("gh", ["auth", "status"], { reject: false });
129
+ if (auth.exitCode === 0) {
130
+ return {
131
+ ok: true,
132
+ severity: "info",
133
+ detail: "`gh` authenticated via keyring/OAuth (no env var needed)",
134
+ };
135
+ }
136
+ } catch { /* fall through to FAIL */ }
114
137
  return {
115
138
  ok: false,
116
139
  severity: "fail",
117
- detail: "No GH_TOKEN/GITHUB_TOKEN; `gh` is installed but auth status unknown",
140
+ detail: "No GH_TOKEN/GITHUB_TOKEN and `gh auth status` reports not logged in",
118
141
  fix: "Run 'gh auth login' or export GH_TOKEN=...",
119
142
  extra: { hasGh: true },
120
143
  };
@@ -41,6 +41,12 @@
41
41
  * @property {Object} [changes] - runtime config overrides to merge into the active session
42
42
  */
43
43
 
44
+ /**
45
+ * @typedef {Object} DegradableSpec
46
+ * @property {string[]} disables - dot-paths del config a desactivar si el check falla (e.g. ["git.auto_pr", "git.auto_push"])
47
+ * @property {string} warn - mensaje WARN que se muestra al usuario explicando qué se desactivó y por qué
48
+ */
49
+
44
50
  /**
45
51
  * @typedef {Object} Check
46
52
  * @property {string} name - slug, unique across all checks (e.g. "node-version")
@@ -50,6 +56,10 @@
50
56
  * @property {(ctx: RemediationContext) => Promise<RemediationResult>} [remediate]
51
57
  * @property {string} [describe] - shown to user when strategy is "prompt"
52
58
  * @property {(config: Object) => boolean} [applies] - if false, check is SKIPPED (lazy evaluation). Default: always applies.
59
+ * @property {DegradableSpec} [degradable] - KJC-BUG-0049: si está definido y el check falla, en lugar de abortar
60
+ * el preflight, se desactivan los flags listados en `disables` y se emite
61
+ * WARN con `warn`. Útil para features opcionales (auto_pr, auto_push,
62
+ * sonar advisory, etc.) que no son blockers absolutos.
53
63
  */
54
64
 
55
65
  /**
@@ -177,6 +177,7 @@ export function registerPipeline(program, { pkgVersion }) {
177
177
  .option("-y, --yes", "Auto-confirm prompts for invasive remediations (CI mode)")
178
178
  .option("--json", "Emit machine-readable JSON instead of human output")
179
179
  .option("--verbose", "Include fix hints and timing for every check")
180
+ .option("--project", "Run ONLY project-aware checks (signals, tools per signal, write perms, .env consistency, gh remote access) — useful to validate a specific project before kj run")
180
181
  .action(async (flags) => {
181
182
  const exitCode = await withConfig(pkgVersion, "doctor", {}, (ctx) =>
182
183
  doctorCommand({
@@ -185,6 +186,7 @@ export function registerPipeline(program, { pkgVersion }) {
185
186
  yes: !!flags.yes,
186
187
  json: !!flags.json,
187
188
  verbose: !!flags.verbose,
189
+ projectOnly: !!flags.project,
188
190
  })
189
191
  );
190
192
  if (Number.isInteger(exitCode)) process.exit(exitCode);
@@ -7,6 +7,8 @@ import {
7
7
  planDeleteCommand,
8
8
  planAddHuCommand,
9
9
  planRemoveHuCommand,
10
+ planMigrateResultCommand,
11
+ planFixCommand,
10
12
  } from "../commands/plan.js";
11
13
  import { parseCanvasMarkdown } from "../canvas/parse-md.js";
12
14
  import { validateCanvas } from "../canvas/validate.js";
@@ -149,4 +151,31 @@ export function registerPlan(program, { pkgVersion }) {
149
151
  await planRemoveHuCommand({ config, planId, huId });
150
152
  });
151
153
  });
154
+
155
+ // KJC-TSK-0402: re-ejecuta reviewer + self-fix + structural pass sobre
156
+ // un plan existente. Acepta --prompt opcional con feedback humano que
157
+ // el reviewer inyecta como contexto extra antes de revisar.
158
+ plan.command("fix")
159
+ .description("Relanzar self-fix loop sobre plan existente (con --prompt opcional)")
160
+ .argument("<planId>")
161
+ .option("--prompt <text>", "Feedback del usuario para guiar al reviewer")
162
+ .option("--json", "Output JSON con before/after counts")
163
+ .action(async (planId, flags) => {
164
+ await withConfig(pkgVersion, "plan", flags, async ({ config, logger }) => {
165
+ await planFixCommand({ config, planId, prompt: flags.prompt, logger, json: flags.json });
166
+ });
167
+ });
168
+
169
+ // KJC-TSK-0394 step 4: migración one-shot que siembra `result` en las
170
+ // HUs de los plan files existentes (basado en su status legacy). Solo
171
+ // toca el campo result, NO el status — el cleanup del enum lo hará
172
+ // step 5. Idempotente.
173
+ plan.command("migrate-result")
174
+ .description("Backfill the `result` field on existing plan HUs (KJC-TSK-0394 step 4)")
175
+ .option("--dry-run", "Show what would change without writing")
176
+ .action(async (flags) => {
177
+ await withConfig(pkgVersion, "plan", flags, async ({ config }) => {
178
+ await planMigrateResultCommand({ config, flags: { dryRun: flags.dryRun } });
179
+ });
180
+ });
152
181
  }
@@ -0,0 +1,26 @@
1
+ // KJC-TSK-0414 PR2: registra `kj standby list` + `kj standby resume <id>`.
2
+ // `resume` es subcomando de standby para no colisionar con el `kj resume`
3
+ // pre-existente (Solomon pauses con --answer). Modelos mentales distintos:
4
+ // - `kj resume <id> --answer X` → Solomon pauseado (existente)
5
+ // - `kj standby resume <id>` → run hibernada por quota
6
+ import { resumeCommand, standbyListCommand } from "../commands/standby.js";
7
+
8
+ export function registerStandby(program) {
9
+ const standby = program.command("standby").description("Sesiones hibernadas (QUOTA_EXHAUSTED_DAILY etc.)");
10
+ standby
11
+ .command("list", { isDefault: true })
12
+ .description("Lista sesiones pendientes de reanudar")
13
+ .option("--json", "Output JSON")
14
+ .action(async (flags) => {
15
+ await standbyListCommand({ json: flags.json });
16
+ });
17
+
18
+ standby
19
+ .command("resume")
20
+ .description("Reanuda una sesión hibernada (re-spawn comando original)")
21
+ .argument("<sessionId>")
22
+ .option("--force", "Reanudar aunque cooldownUntil todavía esté en el futuro (no recomendado)")
23
+ .action(async (sessionId, flags) => {
24
+ await resumeCommand({ sessionId, force: Boolean(flags.force) });
25
+ });
26
+ }
package/src/cli.js CHANGED
@@ -9,6 +9,7 @@ import { registerPlan } from "./cli/register-plan.js";
9
9
  import { registerRolesSkills } from "./cli/register-roles-skills.js";
10
10
  import { registerMeta } from "./cli/register-meta.js";
11
11
  import { registerSonar } from "./cli/register-sonar.js";
12
+ import { registerStandby } from "./cli/register-standby.js";
12
13
  import { printUpdateNotice } from "./utils/update-check.js";
13
14
  import { printWelcomeScreen } from "./utils/welcome.js";
14
15
 
@@ -44,6 +45,7 @@ registerPlan(program, { pkgVersion: PKG_VERSION });
44
45
  registerRolesSkills(program, { pkgVersion: PKG_VERSION });
45
46
  registerMeta(program, { pkgVersion: PKG_VERSION });
46
47
  registerSonar(program);
48
+ registerStandby(program);
47
49
 
48
50
  /**
49
51
  * Find subcommands whose name is "close enough" to a typo. Used by the
@@ -3,6 +3,7 @@ import { assertAgentsAvailable } from "../agents/availability.js";
3
3
  import { resolveRole } from "../config.js";
4
4
  import { buildArchitectPrompt, parseArchitectOutput } from "../prompts/architect.js";
5
5
  import { createCliProgressReporter } from "../utils/cli-progress.js";
6
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
6
7
 
7
8
  function formatLayers(layers, lines) {
8
9
  lines.push("### Layers");
@@ -71,8 +72,11 @@ export async function architectCommand({ task, config, logger, context, json })
71
72
  const progress = createCliProgressReporter({ role: "architect" });
72
73
  let result;
73
74
  try {
74
- result = await agent.runTask({ prompt, onOutput: progress.onOutput, role: "architect" });
75
- progress.finish(result.ok ? "done" : "failed");
75
+ result = await withBrainRecovery({
76
+ agent, taskArgs: { prompt, onOutput: progress.onOutput, role: "architect" },
77
+ role: "architect", provider: architectRole.provider, logger,
78
+ });
79
+ progress.finish(result.ok ? "done" : (result.action || "failed"));
76
80
  } catch (err) { progress.finish("failed"); throw err; }
77
81
 
78
82
  if (!result.ok) {
@@ -1,6 +1,7 @@
1
1
  import { createAgent } from "../agents/index.js";
2
2
  import { assertAgentsAvailable } from "../agents/availability.js";
3
3
  import { resolveRole } from "../config.js";
4
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
4
5
  import { createCliProgressReporter } from "../utils/cli-progress.js";
5
6
  import { buildDiscoverPrompt, parseDiscoverOutput } from "../prompts/discover.js";
6
7
 
@@ -75,8 +76,11 @@ export async function discoverCommand({ task, config, logger, mode, json }) {
75
76
  const progress = createCliProgressReporter({ role: "discover" });
76
77
  let result;
77
78
  try {
78
- result = await agent.runTask({ prompt, onOutput: progress.onOutput, role: "discover" });
79
- progress.finish(result.ok ? "done" : "failed");
79
+ result = await withBrainRecovery({
80
+ agent, taskArgs: { prompt, onOutput: progress.onOutput, role: "discover" },
81
+ role: "discover", provider: discoverRole.provider, logger,
82
+ });
83
+ progress.finish(result.ok ? "done" : (result.action || "failed"));
80
84
  } catch (err) { progress.finish("failed"); throw err; }
81
85
 
82
86
  if (!result.ok) {
@@ -26,13 +26,25 @@ import { getTokenChecks } from "../checks/tokens.js";
26
26
  import { getMcpHealthChecks } from "../checks/mcp-health.js";
27
27
  import { getSkillsChecks } from "../checks/skills.js";
28
28
  import { getDirSetupChecks } from "../checks/dir-setup.js";
29
+ import { getProjectChecks } from "../checks/project-checks.js";
29
30
 
30
31
  /**
31
32
  * Build the list of Check objects applicable to the current config.
33
+ *
34
+ * KJC-TSK-0393: cuando `projectOnly === true`, devuelve SOLO los project-aware
35
+ * checks (signals, tools per signal, write perms, .env consistency, gh remote
36
+ * access). Útil para validar un proyecto específico sin re-correr todos los
37
+ * checks globales (CLIs, node, sonar, etc.).
38
+ *
32
39
  * @param {Object} config
40
+ * @param {{ projectOnly?: boolean }} [opts]
33
41
  * @returns {import("../checks/types.js").Check[]}
34
42
  */
35
- function buildChecks(config) {
43
+ function buildChecks(config, { projectOnly = false } = {}) {
44
+ const projectDir = config?.projectDir || process.cwd();
45
+ if (projectOnly) {
46
+ return getProjectChecks({ projectDir });
47
+ }
36
48
  return [
37
49
  ...getSystemChecks(),
38
50
  ...getNodeChecks(),
@@ -46,6 +58,7 @@ function buildChecks(config) {
46
58
  ...getSkillsChecks(),
47
59
  ...getCiChecks(),
48
60
  ...getRtkChecks(),
61
+ ...getProjectChecks({ projectDir }),
49
62
  ];
50
63
  }
51
64
 
@@ -77,8 +90,8 @@ function confirmViaTty(describe) {
77
90
  * @param {{ config: Object, checkOnly?: boolean, yes?: boolean, onConfirm?: Function, logger?: Object }} args
78
91
  * @returns {Promise<import("../checks/types.js").RunReport>}
79
92
  */
80
- async function runDoctor({ config, checkOnly = false, yes = false, onConfirm, logger }) {
81
- const checks = buildChecks(config);
93
+ async function runDoctor({ config, checkOnly = false, yes = false, onConfirm, logger, projectOnly = false }) {
94
+ const checks = buildChecks(config, { projectOnly });
82
95
  return runCheckPipeline(checks, { config }, {
83
96
  mode: checkOnly ? "check-only" : "fix",
84
97
  yes,
@@ -105,8 +118,8 @@ export async function runChecks({ config }) {
105
118
  * @param {{ config: Object, checkOnly?: boolean, yes?: boolean, json?: boolean, verbose?: boolean }} args
106
119
  * @returns {Promise<number>}
107
120
  */
108
- export async function doctorCommand({ config, checkOnly = false, yes = false, json = false, verbose = false }) {
109
- const report = await runDoctor({ config, checkOnly, yes });
121
+ export async function doctorCommand({ config, checkOnly = false, yes = false, json = false, verbose = false, projectOnly = false }) {
122
+ const report = await runDoctor({ config, checkOnly, yes, projectOnly });
110
123
 
111
124
  if (json) {
112
125
  console.log(JSON.stringify(report, null, 2));
@@ -127,6 +127,52 @@ async function askPerRoleProviders(wizard, available, config, { coder, reviewer
127
127
  }
128
128
  }
129
129
 
130
+ /**
131
+ * KJC-TSK-0415: Plan B — fallback chain. Cuando el provider primario se
132
+ * queda sin créditos (QUOTA_EXHAUSTED_DAILY/MONTHLY) y el cooldown excede
133
+ * max_wait_hours, Brain switchea al fallback en vez de hibernar.
134
+ *
135
+ * Solo pregunta si hay >= 2 providers disponibles (sin alternativas reales
136
+ * no tiene sentido el fallback).
137
+ */
138
+ async function askFallbackChain(wizard, available, config, { coder, reviewer }, logger) {
139
+ if (available.length < 2) return;
140
+
141
+ logger.info("");
142
+ logger.info("Plan B — fallback automático cuando un provider se queda sin créditos:");
143
+ const enable = await wizard.confirm(
144
+ " ¿Configurar fallback? (default: 12h; si el cooldown supera ese tiempo, switch al provider alternativo)",
145
+ true
146
+ );
147
+ if (!enable) return;
148
+
149
+ const maxHoursRaw = await wizard.input(" Máximo tiempo de espera antes de fallback (horas):", "12");
150
+ const maxHours = Number.parseFloat(maxHoursRaw) || 12;
151
+
152
+ config.roles = config.roles || {};
153
+ const KEY_ROLES = [
154
+ { key: "coder", label: "coder", primary: coder },
155
+ { key: "reviewer", label: "reviewer", primary: reviewer },
156
+ { key: "planner", label: "planner", primary: coder },
157
+ ];
158
+ for (const role of KEY_ROLES) {
159
+ config.roles[role.key] = config.roles[role.key] || {};
160
+ const opts = [{ label: "Sin fallback (hibernar)", value: "__none__", available: true }];
161
+ for (const a of available) {
162
+ if (a.name === role.primary) continue;
163
+ opts.push({ label: a.name, value: a.name, available: true });
164
+ }
165
+ const choice = await wizard.select(` Fallback de ${role.label} cuando ${role.primary} se quede sin créditos:`, opts);
166
+ if (choice === "__none__") {
167
+ delete config.roles[role.key].fallback;
168
+ logger.info(` -> ${role.key}: sin fallback (hibernará)`);
169
+ } else {
170
+ config.roles[role.key].fallback = { provider: choice, max_wait_hours: maxHours };
171
+ logger.info(` -> ${role.key}: ${role.primary} → ${choice} (tras ${maxHours}h)`);
172
+ }
173
+ }
174
+ }
175
+
130
176
  async function askMethodology(wizard, config, logger) {
131
177
  const methodology = await wizard.select("Development methodology:", [
132
178
  { label: "TDD (test-driven development)", value: "tdd", available: true },
@@ -256,6 +302,7 @@ async function runWizard(config, logger) {
256
302
  logger.info(` -> HU Board: ${enableHuBoard ? "enabled (auto-start on kj run)" : "disabled"}`);
257
303
 
258
304
  await askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger);
305
+ await askFallbackChain(wizard, available, config, { coder, reviewer }, logger);
259
306
  await askMethodology(wizard, config, logger);
260
307
  await askLanguages(wizard, config, logger);
261
308
 
@@ -25,16 +25,31 @@ export function formatPlan(plan) {
25
25
  return lines.join("\n");
26
26
  }
27
27
 
28
+ /**
29
+ * Convierte el id largo de una dep `hu_<planId>_<NNN>` al short_id de
30
+ * la HU correspondiente cuando lo conoce. Mantiene el id largo si la
31
+ * dep no tiene short_id (HU legacy pre-humanización).
32
+ */
33
+ function displayDep(depId, hus) {
34
+ const hu = hus.find((h) => h.id === depId);
35
+ if (hu?.short_id) return hu.short_id;
36
+ return depId;
37
+ }
38
+
28
39
  export function formatHuTable(hus) {
29
40
  if (!hus?.length) return " (no HUs)";
41
+ // Humanización IDs: si la HU tiene short_id ("INFRA-001"), úsalo
42
+ // como columna ID; si no, fallback al id largo (HU legacy).
43
+ const idOf = (h) => h.short_id || h.id;
30
44
  const lines = [];
45
+ const maxId = Math.min(20, Math.max(...hus.map((h) => idOf(h).length || 0)));
31
46
  const maxTitle = Math.min(50, Math.max(...hus.map(h => h.title?.length || 0)));
32
- lines.push(` ${"ID".padEnd(30)} ${"Title".padEnd(maxTitle)} ${"Type".padEnd(10)} ${"Status".padEnd(10)} Deps`);
33
- lines.push(` ${"─".repeat(30)} ${"─".repeat(maxTitle)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(15)}`);
47
+ lines.push(` ${"ID".padEnd(maxId)} ${"Title".padEnd(maxTitle)} ${"Type".padEnd(10)} ${"Status".padEnd(10)} Deps`);
48
+ lines.push(` ${"─".repeat(maxId)} ${"─".repeat(maxTitle)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(15)}`);
34
49
  for (const hu of hus) {
35
50
  const title = (hu.title || "").slice(0, maxTitle).padEnd(maxTitle);
36
- const deps = (hu.blocked_by || []).join(", ") || "—";
37
- lines.push(` ${hu.id.padEnd(30)} ${title} ${(hu.task_type || "sw").padEnd(10)} ${(hu.status || "?").padEnd(10)} ${deps}`);
51
+ const deps = (hu.blocked_by || []).map((d) => displayDep(d, hus)).join(", ") || "—";
52
+ lines.push(` ${idOf(hu).padEnd(maxId)} ${title} ${(hu.task_type || "sw").padEnd(10)} ${(hu.status || "?").padEnd(10)} ${deps}`);
38
53
  }
39
54
  return lines.join("\n");
40
55
  }