karajan-code 2.14.3 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/agents/claude-agent.js +2 -2
  4. package/src/agents/codex-agent.js +1 -1
  5. package/src/audit/agent-readiness.js +1 -1
  6. package/src/audit/basal-cost.js +4 -4
  7. package/src/audit/webperf-input.js +1 -1
  8. package/src/brain/agent-error-classifier.js +101 -0
  9. package/src/brain/standby-scheduler.js +92 -0
  10. package/src/brain/standby-store.js +129 -0
  11. package/src/brain/with-brain-recovery.js +178 -0
  12. package/src/canvas/parse-md.js +1 -1
  13. package/src/cli/register-plan.js +29 -0
  14. package/src/cli/register-standby.js +26 -0
  15. package/src/cli.js +2 -0
  16. package/src/commands/architect.js +6 -2
  17. package/src/commands/audit.js +1 -1
  18. package/src/commands/discover.js +6 -2
  19. package/src/commands/init.js +47 -0
  20. package/src/commands/plan/_shared.js +19 -4
  21. package/src/commands/plan/fix.js +99 -0
  22. package/src/commands/plan/generate.js +112 -27
  23. package/src/commands/plan/index.js +2 -5
  24. package/src/commands/plan/migrate-result.js +41 -0
  25. package/src/commands/plan.js +2 -1
  26. package/src/commands/researcher.js +6 -2
  27. package/src/commands/run.js +33 -1
  28. package/src/commands/standby.js +114 -0
  29. package/src/commands/triage.js +6 -2
  30. package/src/commands/webperf.js +1 -1
  31. package/src/config/schema.js +40 -0
  32. package/src/domains/domain-synthesizer.js +1 -1
  33. package/src/git/hu-automation.js +2 -2
  34. package/src/git/hu-snapshot.js +105 -0
  35. package/src/guards/policy-resolver.js +38 -1
  36. package/src/hu/auto-generator.js +1 -1
  37. package/src/hu/lazy-planner.js +6 -1
  38. package/src/hu/model-router.js +108 -0
  39. package/src/hu/splitting-generator.js +6 -1
  40. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
  41. package/src/orchestrator/drivers/run-hu-batch.js +30 -4
  42. package/src/orchestrator/repair/repair-detector.js +3 -3
  43. package/src/plan/adr-generator.js +5 -5
  44. package/src/plan/plan-executor.js +13 -4
  45. package/src/plan/plan-fixer.js +36 -4
  46. package/src/plan/plan-hu-ops.js +24 -1
  47. package/src/plan/plan-id.js +21 -1
  48. package/src/plan/plan-migrate-result.js +24 -0
  49. package/src/plan/plan-reviewer.js +40 -3
  50. package/src/plan/plan-schema.js +128 -1
  51. package/src/plan/plan-store.js +64 -9
  52. package/src/plan/plan-structural-pass.js +122 -0
  53. package/src/plan/plan-validation.js +82 -0
  54. package/src/plan/tests-synthesizer.js +8 -3
  55. package/src/prompts/planner.js +27 -1
  56. package/src/roles/agent-role.js +18 -3
  57. package/src/roles/perf-role.js +1 -1
  58. package/src/roles/planner-role.js +10 -3
  59. package/src/roles/solomon-role.js +10 -4
  60. package/src/session/journal/decisions-writer.js +1 -1
  61. package/src/session/journal/iteration-logger.js +1 -1
  62. package/src/session/journal/summary-writer.js +1 -1
  63. package/src/skills/addyosmani-catalog.js +1 -1
  64. package/src/skills/semantic-detector.js +17 -2
  65. package/src/skills/skills-cache.js +1 -1
  66. package/src/sonar/api.js +22 -4
  67. package/src/sonar/config-resolver.js +1 -1
  68. package/src/sonar/issue-filter.js +110 -0
  69. package/src/sonar/scanner.js +1 -1
  70. package/src/utils/cli-progress.js +58 -1
  71. package/src/utils/derive-project-name-from-cwd.js +3 -3
  72. package/src/utils/garbage-collector.js +85 -2
  73. package/src/utils/injection-guard.js +1 -1
  74. package/src/utils/messages.js +1 -1
  75. package/src/utils/run-registry.js +115 -0
package/README.md CHANGED
@@ -23,7 +23,9 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.14.3 released** — Patch. Three preflight improvements surfaced by the first real `kj run` against a greenfield project (greta-app): (1) `gh` keyring auth is now recognized — Karajan no longer demands `GH_TOKEN` env var when the user has done `gh auth login --web` (KJC-BUG-0049 puntual). (2) New **degradable checks** system: when a preflight check fails on an optional feature (e.g. `auto_pr` without gh token), the feature is silently disabled with a WARN instead of aborting the entire run. (3) New **project-aware preflight**: detects signals (Dockerfile / firebase.json / pyproject.toml / Cargo.toml / *.tf / .env.example), checks the corresponding tools are installed, validates write permissions on `projectDir/.kj/.karajan`, compares `.env` vs `.env.example`, and validates `gh` push access to the actual remote (not just global auth). New command `kj doctor --project` runs only this phase. 2 PRs (#690, #691). 4608/4608 tests passing. Safe upgrade from 2.14.2.
26
+ > **v2.16.0 released** — Minor. Quality-focused release: **deterministic Sonar false-positive filter** (KJC-TSK-0416), wire universal de Brain Recovery completado en `semantic-detector` (TSK-0413 step D), codemod `replace(/regex/g, …)` → `replaceAll(/regex/g, …)` en 41 sitios, y limpieza de hallazgos del propio `kj audit` v2.15.0. The Sonar filter combines two mechanisms: (1) **static rules** `{ rule, filePattern, reason }` from a built-in catalog (covering common false positives like `javascript:S2699` on `tests/architecture/`) plus extensible via `config.sonar.false_positives`; (2) **inline ignores** with `// karajan-sonar-ignore: <ruleId>` on the issue line. Result: the coder stops burning tokens "fixing" non-broken assertions. Brain Recovery now wraps **every** AI call in the pipeline — `semantic-detector` was the last legacy caller. 4 846/4 846 tests passing across 401 files. Safe upgrade from 2.15.0.
27
+ >
28
+ > **v2.15.0** — Minor. Three epics, 30+ commits, ~4 000 LOC. (1) **Brain Recovery** (KJC-PCS-0044): universal error classifier with 7 rich classes (rate_limit_short, quota_daily, quota_monthly, api_down, auth_failed, network_timeout, silenced, unknown_fatal), `withBrainRecovery` wrapper wired into ALL agent invocations (no silent failures), persistent hibernation in `~/.kj/standby/<id>.json` with event-driven scheduler (no polling), `kj standby list` + `kj standby resume`, board reconcile at startup, **fallback chain** when quota exhausted with `retryAfter > 12h` (Claude → Codex → OpenCode → Aider, configurable per role via `kj init`). (2) **Model Routing + Undo** (KJC-PCS-0043): each HU gets `coder_model` + `reviewer_model` with cross-provider review by default (claude↔codex), per-HU override from board modal, OpenCode + Aider as first-class providers, ⏪ Undo button restores files via git snapshots. (3) **Self-Healing Plan** (KJC-PCS-0042): structural integrity pass breaks cycles + cleans orphan refs, smart convergence guard for self-fix loop, `kj plan fix [planId] [--prompt]` for iterating without regenerating, skip Sonar for SPIKE/DOC/RESEARCH HUs, eliminate Failed kanban column. 4 835/4 835 tests passing across 400 files. Safe upgrade from 2.14.3.
27
29
  >
28
30
  > **v2.14.2** — Patch. More dogfooding fallout from GRETA Plan 2 v2.14.1: the **▶ Run button** on the HU Board appeared on every `pending` card regardless of `blocked_by` (you could launch a HU whose deps don't exist yet — the user caught it instantly), and titles on the board lost their **`[EPICA]` prefix** at some point in v2.14.x evolution, making it impossible to tell at a glance which area of the plan a card belonged to. Fixes: `canRunHu` now requires `blockedBy.length === 0` before showing ▶; the planner prompt now demands `description: "[EPICA] one-sentence description"` with INFRA/SHARED fallbacks. Plus a new doc `docs/task-templates/spec-conventions.md` collecting the 6 SPEC conventions the planner v2.14.x understands (épicas, scope exclusions, transversal deps, reuse, async observers, explicit deps), so users don't have to rediscover them by dogfooding. 2 PRs (#687, #688). 4584/4584 tests passing. Safe upgrade from 2.14.1.
29
31
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.14.3",
3
+ "version": "2.16.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -156,7 +156,7 @@ function summarizeToolCall(name, input = {}) {
156
156
  case "Glob": return `Glob ${input.pattern || ""}`.trim();
157
157
  case "Grep": return `Grep "${(input.pattern || "").slice(0, 60)}"${input.path ? ` in ${rel(input.path)}` : ""}`;
158
158
  case "Bash": {
159
- const cmd = String(input.command || "").replace(/\s+/g, " ").slice(0, 100);
159
+ const cmd = String(input.command || "").replaceAll(/\s+/g, " ").slice(0, 100);
160
160
  return `Bash $ ${cmd}`;
161
161
  }
162
162
  case "TodoWrite": return `Todo ${(input.todos || []).length} items`;
@@ -259,7 +259,7 @@ export function createStreamJsonFilter(onOutput) {
259
259
  } else if (block.type === "tool_use") {
260
260
  onOutput({ stream, line: summarizeToolCall(block.name, block.input), kind: "tool" });
261
261
  } else if (block.type === "thinking" && block.thinking) {
262
- onOutput({ stream, line: `thinking: ${block.thinking.slice(0, 80).replace(/\s+/g, " ")}…`, kind: "thinking" });
262
+ onOutput({ stream, line: `thinking: ${block.thinking.slice(0, 80).replaceAll(/\s+/g, " ")}…`, kind: "thinking" });
263
263
  }
264
264
  }
265
265
  return;
@@ -11,7 +11,7 @@ import { resolveBin } from "./resolve-bin.js";
11
11
  function extractCodexTokens(stdout) {
12
12
  const match = (stdout || "").match(/tokens?\s+used\s*\n\s*([\d,]+)/i);
13
13
  if (!match) return null;
14
- const total = Number(match[1].replace(/,/g, ""));
14
+ const total = Number(match[1].replaceAll(/,/g, ""));
15
15
  if (!Number.isFinite(total) || total <= 0) return null;
16
16
  return { tokens_in: 0, tokens_out: total };
17
17
  }
@@ -178,7 +178,7 @@ function safeRead(p) {
178
178
  * line counts keeps later byte-offset checks stable.
179
179
  */
180
180
  function stripFencedCodeBlocks(text) {
181
- return text.replace(/^([ \t]*)(```|~~~)[^\n]*\n([\s\S]*?)\n\1\2[^\n]*$/gm, (match) => {
181
+ return text.replaceAll(/^([ \t]*)(```|~~~)[^\n]*\n([\s\S]*?)\n\1\2[^\n]*$/gm, (match) => {
182
182
  const lineCount = match.split("\n").length;
183
183
  return "\n".repeat(lineCount - 1);
184
184
  });
@@ -18,7 +18,7 @@ const EXCLUDE_DIRS = new Set([
18
18
  ]);
19
19
 
20
20
  function slugify(projectDir) {
21
- return path.basename(projectDir).replace(/[^a-zA-Z0-9_-]/g, "_");
21
+ return path.basename(projectDir).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
22
22
  }
23
23
 
24
24
  async function walkSourceFiles(dir) {
@@ -144,9 +144,9 @@ function resolveImportSpecifier(fromFile, specifier) {
144
144
  // need the actual path inside the quotes to resolve the target module.
145
145
  function stripStringLiterals(content) {
146
146
  return content
147
- .replace(/`[^`]*`/g, "``")
148
- .replace(/"(?:\\.|[^"\\])*"/g, '""')
149
- .replace(/'(?:\\.|[^'\\])*'/g, "''");
147
+ .replaceAll(/`[^`]*`/g, "``")
148
+ .replaceAll(/"(?:\\.|[^"\\])*"/g, '""')
149
+ .replaceAll(/'(?:\\.|[^'\\])*'/g, "''");
150
150
  }
151
151
 
152
152
  async function findDeadExports(sourceFiles) {
@@ -39,7 +39,7 @@ function webperfHome() {
39
39
  }
40
40
 
41
41
  function projectSlug(projectDir) {
42
- return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
42
+ return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
43
43
  }
44
44
 
45
45
  /**
@@ -0,0 +1,101 @@
1
+ // KJC-TSK-0411: clasificador universal de errores de agente IA.
2
+ //
3
+ // Devuelve clase rica + metadata accionable para que Brain (TSK-0412)
4
+ // decida retry/standby/hibernar/abortar sin tener que repetir lógica
5
+ // de parsing en cada stage.
6
+ //
7
+ // Reutiliza parseCooldown del rate-limit-detector legacy para no
8
+ // duplicar regex. Este módulo extiende la clasificación:
9
+ // - RATE_LIMIT_SHORT vs QUOTA_EXHAUSTED_DAILY se distinguen por
10
+ // cooldownMs (threshold 1h).
11
+ // - SILENCED se detecta por exit 143/null + "killed after Nms".
12
+ // - AUTH_FAILED por 401/403 + "Unauthorized"/"invalid api key".
13
+ // - NETWORK_TIMEOUT por ECONN*/socket hang up sin cooldown.
14
+
15
+ import { parseCooldown } from "../utils/rate-limit-detector.js";
16
+
17
+ export const ERROR_CLASS = Object.freeze({
18
+ RATE_LIMIT_SHORT: "RATE_LIMIT_SHORT",
19
+ QUOTA_EXHAUSTED_DAILY: "QUOTA_EXHAUSTED_DAILY",
20
+ // KJC-TSK-0415: Anthropic Max 20x cambia a $200/mes Agent SDK desde
21
+ // 15-jun-2026. Cuando llegues al cap mensual, el reset es 1-mes —
22
+ // muy distinto del daily de Claude Pro. Esta clase distingue ambos.
23
+ QUOTA_EXHAUSTED_MONTHLY: "QUOTA_EXHAUSTED_MONTHLY",
24
+ API_DOWN: "API_DOWN",
25
+ AUTH_FAILED: "AUTH_FAILED",
26
+ NETWORK_TIMEOUT: "NETWORK_TIMEOUT",
27
+ SILENCED: "SILENCED",
28
+ UNKNOWN_FATAL: "UNKNOWN_FATAL",
29
+ });
30
+
31
+ const ONE_HOUR_MS = 60 * 60 * 1000;
32
+ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
33
+
34
+ const AUTH_PATTERNS = /\b401\b|\b403\b|unauthorized|invalid\s+api\s+key|authentication\s+failed|expired\s+token/i;
35
+ const SILENCED_PATTERNS = /killed\s+after\s+\d+\s*ms|silence\s*timeout|no\s+output\s+for\s+\d+/i;
36
+ const RATE_LIMIT_PATTERNS = /usage\s+limit|rate\s*limit|too\s+many\s+requests|\b429\b|throttl|exceeded\s+your\s+current\s+quota|resource\s+exhausted|quota\s+exceeded|token\s+limit\s+reached|monthly\s+limit|daily\s+limit/i;
37
+ const API_DOWN_PATTERNS = /\b50[0-4]\b|bad\s+gateway|service\s+unavailable|gateway\s+timeout|overloaded|internal\s+server\s+error/i;
38
+ const NETWORK_PATTERNS = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket\s+hang\s+up|fetch\s+failed|network\s+error/i;
39
+
40
+ function pickMessage(combined, pattern) {
41
+ const line = combined.split("\n").find((l) => pattern.test(l));
42
+ return (line || combined).trim().slice(0, 300);
43
+ }
44
+
45
+ /**
46
+ * @param {object} args
47
+ * @param {string} [args.provider] - claude|codex|gemini|opencode|aider
48
+ * @param {string} [args.stdout]
49
+ * @param {string} [args.stderr]
50
+ * @param {number|null} [args.exitCode]
51
+ * @returns {{ class: string, provider: string, message: string, retryAfter: number|null, retryUntil: string|null, recoverable: boolean, exitCode: number|null }}
52
+ */
53
+ export function classifyAgentError({ provider = "unknown", stdout = "", stderr = "", exitCode = null }) {
54
+ const combined = `${stderr}\n${stdout}`;
55
+ const base = { provider, exitCode, retryAfter: null, retryUntil: null };
56
+
57
+ // 1. AUTH_FAILED tiene prioridad absoluta — no es recuperable, escalar al usuario.
58
+ if (AUTH_PATTERNS.test(combined)) {
59
+ return { ...base, class: ERROR_CLASS.AUTH_FAILED, message: pickMessage(combined, AUTH_PATTERNS), recoverable: false };
60
+ }
61
+
62
+ // 2. SILENCED: subprocess killed por silenceTimeout. exit=143 (SIGTERM) o null.
63
+ // También detectamos por mensaje aunque exitCode no encaje (resiliente).
64
+ if (SILENCED_PATTERNS.test(combined) || (exitCode === 143 && !combined.trim())) {
65
+ return { ...base, class: ERROR_CLASS.SILENCED, message: pickMessage(combined, SILENCED_PATTERNS) || "Agent silenciado por timeout", recoverable: true };
66
+ }
67
+
68
+ // 3. RATE_LIMIT con cooldown. Thresholds:
69
+ // cooldown > 7d → MONTHLY (Anthropic Agent SDK $200/mes desde jun-2026)
70
+ // cooldown > 1h → DAILY (Claude Pro daily, OpenAI rate-limit-by-day)
71
+ // cooldown <= 1h → SHORT (rate limit transitorio)
72
+ if (RATE_LIMIT_PATTERNS.test(combined)) {
73
+ const msg = pickMessage(combined, RATE_LIMIT_PATTERNS);
74
+ const { cooldownUntil, cooldownMs } = parseCooldown(msg) ?? {};
75
+ let quotaClass;
76
+ if (cooldownMs != null && cooldownMs > ONE_WEEK_MS) quotaClass = ERROR_CLASS.QUOTA_EXHAUSTED_MONTHLY;
77
+ else if (cooldownMs != null && cooldownMs > ONE_HOUR_MS) quotaClass = ERROR_CLASS.QUOTA_EXHAUSTED_DAILY;
78
+ else quotaClass = ERROR_CLASS.RATE_LIMIT_SHORT;
79
+ return {
80
+ ...base,
81
+ class: quotaClass,
82
+ message: msg,
83
+ retryAfter: cooldownMs ?? null,
84
+ retryUntil: cooldownUntil ?? null,
85
+ recoverable: true,
86
+ };
87
+ }
88
+
89
+ // 4. NETWORK errors antes que API_DOWN — ECONN* es a nivel socket, no respuesta HTTP.
90
+ if (NETWORK_PATTERNS.test(combined)) {
91
+ return { ...base, class: ERROR_CLASS.NETWORK_TIMEOUT, message: pickMessage(combined, NETWORK_PATTERNS), recoverable: true };
92
+ }
93
+
94
+ // 5. API_DOWN: 5xx, overloaded.
95
+ if (API_DOWN_PATTERNS.test(combined)) {
96
+ return { ...base, class: ERROR_CLASS.API_DOWN, message: pickMessage(combined, API_DOWN_PATTERNS), recoverable: true };
97
+ }
98
+
99
+ // 6. UNKNOWN_FATAL: salida no clasificada con exitCode != 0.
100
+ return { ...base, class: ERROR_CLASS.UNKNOWN_FATAL, message: combined.trim().slice(0, 300) || `exit ${exitCode}`, recoverable: false };
101
+ }
@@ -0,0 +1,92 @@
1
+ // KJC-TSK-0414 PR 1: scheduler event-driven para hibernación.
2
+ //
3
+ // Diseño: setTimeout único por sesión, dispara EXACTAMENTE en cooldownUntil.
4
+ // Cero polling. Si el proceso muere antes del fire, reconcileAll() al
5
+ // arrancar el board re-programa los timeouts pendientes (o reanuda
6
+ // inmediatamente los expirados).
7
+
8
+ import { listPendingStandby, acquireStandbyLock, markStandbyDone } from "./standby-store.js";
9
+
10
+ // Mapa sessionId → NodeJS.Timeout — accesible para test/cancel.
11
+ const _scheduled = new Map();
12
+
13
+ export function _resetScheduledForTests() {
14
+ for (const t of _scheduled.values()) clearTimeout(t);
15
+ _scheduled.clear();
16
+ }
17
+
18
+ /**
19
+ * Programa un único setTimeout que dispara `onResume(sessionId)` en cooldownUntil.
20
+ *
21
+ * @param {object} args
22
+ * @param {string} args.sessionId
23
+ * @param {string} args.cooldownUntil - ISO timestamp
24
+ * @param {Function} args.onResume - callback(sessionId) cuando llega el momento
25
+ * @param {object} [args.logger]
26
+ * @returns {{ scheduled: boolean, msUntil?: number, immediate?: boolean }}
27
+ */
28
+ export function scheduleResume({ sessionId, cooldownUntil, onResume, logger }) {
29
+ if (!sessionId || !cooldownUntil || typeof onResume !== "function") {
30
+ return { scheduled: false };
31
+ }
32
+ cancelScheduled(sessionId);
33
+ const targetMs = new Date(cooldownUntil).getTime();
34
+ if (Number.isNaN(targetMs)) return { scheduled: false };
35
+ const delay = targetMs - Date.now();
36
+ if (delay <= 0) {
37
+ // Ya expiró — reanudar inmediato (caso: board reiniciado tras llegar la hora).
38
+ logger?.info?.(`[standby-scheduler] ${sessionId}: cooldown ya expirado, resume inmediato`);
39
+ Promise.resolve().then(() => onResume(sessionId)).catch(() => { /* swallow */ });
40
+ return { scheduled: true, immediate: true };
41
+ }
42
+ const timer = setTimeout(() => {
43
+ _scheduled.delete(sessionId);
44
+ logger?.info?.(`[standby-scheduler] ${sessionId}: cooldown alcanzado, disparando resume`);
45
+ try { onResume(sessionId); } catch (err) { logger?.warn?.(`[standby-scheduler] onResume threw: ${err.message}`); }
46
+ }, delay);
47
+ if (typeof timer.unref === "function") timer.unref();
48
+ _scheduled.set(sessionId, timer);
49
+ logger?.info?.(`[standby-scheduler] ${sessionId}: scheduled in ${Math.round(delay / 1000)}s (until ${cooldownUntil})`);
50
+ return { scheduled: true, msUntil: delay };
51
+ }
52
+
53
+ /** Cancela el setTimeout de una sesión (caso: usuario hizo kj resume manual). */
54
+ export function cancelScheduled(sessionId) {
55
+ const t = _scheduled.get(sessionId);
56
+ if (!t) return false;
57
+ clearTimeout(t);
58
+ _scheduled.delete(sessionId);
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * Reconciliación al arrancar el board / kj. Lee todas las sesiones pendientes
64
+ * y para cada una:
65
+ * - cooldownUntil < now → onResume inmediato
66
+ * - cooldownUntil > now → re-programa setTimeout
67
+ *
68
+ * Idempotente: el lockfile en standby-store previene dobles spawns si
69
+ * reconcileAll() corre dos veces concurrentes (board reload).
70
+ *
71
+ * @param {object} args
72
+ * @param {Function} args.onResume - callback(sessionId)
73
+ * @param {object} [args.logger]
74
+ * @returns {{ scheduled: number, immediate: number, total: number }}
75
+ */
76
+ export function reconcileAll({ onResume, logger }) {
77
+ const sessions = listPendingStandby();
78
+ let scheduled = 0;
79
+ let immediate = 0;
80
+ for (const s of sessions) {
81
+ if (!s?.sessionId || !s?.cooldownUntil) continue;
82
+ const r = scheduleResume({ sessionId: s.sessionId, cooldownUntil: s.cooldownUntil, onResume, logger });
83
+ if (r.scheduled && r.immediate) immediate += 1;
84
+ else if (r.scheduled) scheduled += 1;
85
+ }
86
+ logger?.info?.(`[standby-scheduler] reconcile: ${sessions.length} sesiones (${immediate} resume inmediato, ${scheduled} programadas)`);
87
+ return { scheduled, immediate, total: sessions.length };
88
+ }
89
+
90
+ // Re-export para que callers no tengan que importar standby-store si solo
91
+ // necesitan el flow scheduler → lock → done.
92
+ export { acquireStandbyLock, markStandbyDone };
@@ -0,0 +1,129 @@
1
+ // KJC-TSK-0414 PR 1: persistencia de sesiones hibernadas al disco.
2
+ //
3
+ // Cuando Brain decide hibernar (QUOTA_EXHAUSTED_DAILY con retryAfter > 5min),
4
+ // serializa el estado completo del run en ~/.kj/standby/<sessionId>.json.
5
+ // El proceso de kj run muere (exit 0) tras persistir; el board (o kj resume)
6
+ // lo reanuda cuando llega cooldownUntil.
7
+ //
8
+ // Layout en disco:
9
+ // ~/.kj/standby/
10
+ // <sessionId>.json ← sesiones pendientes (scheduler les mete setTimeout)
11
+ // <sessionId>.lock ← lockfile durante spawn de kj resume
12
+ // done/
13
+ // <sessionId>.json ← finalizadas (consumibles por GC en TSK-0414 PR3)
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+
19
+ const FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
20
+
21
+ function getKjHome() {
22
+ if (process.env.KJ_HOME) return process.env.KJ_HOME;
23
+ if (process.env.VITEST) return path.join(os.tmpdir(), `kj-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`, ".kj");
24
+ return path.join(os.homedir(), ".kj");
25
+ }
26
+
27
+ export function standbyDir() {
28
+ return path.join(getKjHome(), "standby");
29
+ }
30
+
31
+ export function standbyDoneDir() {
32
+ return path.join(standbyDir(), "done");
33
+ }
34
+
35
+ function sanitizeId(sessionId) {
36
+ if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId requerido");
37
+ return sessionId.replaceAll(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
38
+ }
39
+
40
+ /**
41
+ * Persiste el estado completo de un run hibernado.
42
+ *
43
+ * @param {object} state - shape:
44
+ * { sessionId, planId?, huId?, role, iteration?, prompt, agentConfig,
45
+ * retryCount?, cooldownUntil, reason, snapshot_sha?, outcome_so_far? }
46
+ * @returns {string} path absoluto del JSON escrito
47
+ */
48
+ export function persistStandby(state) {
49
+ if (!state || !state.sessionId) throw new Error("state.sessionId requerido");
50
+ if (!state.cooldownUntil) throw new Error("state.cooldownUntil requerido");
51
+ const dir = standbyDir();
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ const file = path.join(dir, `${sanitizeId(state.sessionId)}.json`);
54
+ const payload = {
55
+ ...state,
56
+ createdAt: state.createdAt || new Date().toISOString(),
57
+ persistedAt: new Date().toISOString(),
58
+ };
59
+ fs.writeFileSync(file, JSON.stringify(payload, null, 2), "utf-8");
60
+ return file;
61
+ }
62
+
63
+ /**
64
+ * @param {string} sessionId
65
+ * @returns {object|null}
66
+ */
67
+ export function loadStandby(sessionId) {
68
+ const file = path.join(standbyDir(), `${sanitizeId(sessionId)}.json`);
69
+ if (!fs.existsSync(file)) return null;
70
+ try {
71
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Lista todas las sesiones pendientes (no las de done/).
79
+ * @returns {object[]}
80
+ */
81
+ export function listPendingStandby() {
82
+ const dir = standbyDir();
83
+ if (!fs.existsSync(dir)) return [];
84
+ return fs.readdirSync(dir)
85
+ .filter((f) => FILENAME_RE.test(f))
86
+ .map((f) => {
87
+ try {
88
+ return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
89
+ } catch {
90
+ return null;
91
+ }
92
+ })
93
+ .filter(Boolean);
94
+ }
95
+
96
+ /**
97
+ * Mueve una sesión a done/ — consumible por GC en TSK-0414 PR3.
98
+ * @param {string} sessionId
99
+ * @returns {boolean} true si se movió
100
+ */
101
+ export function markStandbyDone(sessionId) {
102
+ const id = sanitizeId(sessionId);
103
+ const src = path.join(standbyDir(), `${id}.json`);
104
+ if (!fs.existsSync(src)) return false;
105
+ const doneDir = standbyDoneDir();
106
+ fs.mkdirSync(doneDir, { recursive: true });
107
+ const dst = path.join(doneDir, `${id}-${Date.now()}.json`);
108
+ fs.renameSync(src, dst);
109
+ return true;
110
+ }
111
+
112
+ /**
113
+ * Lockfile para evitar dobles spawns de kj resume sobre la misma sesión.
114
+ * Devuelve { acquired: true, release: () => void } o { acquired: false } si ya hay lock.
115
+ *
116
+ * @param {string} sessionId
117
+ * @returns {{ acquired: boolean, release?: Function }}
118
+ */
119
+ export function acquireStandbyLock(sessionId) {
120
+ const id = sanitizeId(sessionId);
121
+ const lockFile = path.join(standbyDir(), `${id}.lock`);
122
+ try {
123
+ fs.mkdirSync(standbyDir(), { recursive: true });
124
+ fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
125
+ return { acquired: true, release: () => { try { fs.unlinkSync(lockFile); } catch { /* */ } } };
126
+ } catch {
127
+ return { acquired: false };
128
+ }
129
+ }
@@ -0,0 +1,178 @@
1
+ // KJC-TSK-0412: wrapper central de recovery. Cualquier stage que invoque
2
+ // un agente IA pasa por aquí. Brain consume classifyAgentError (TSK-0411)
3
+ // y decide: standby corto, backoff exponencial, hibernar, o abortar.
4
+ //
5
+ // Hibernar al disco (acción persistente) es TSK-0414 — esta PR devuelve
6
+ // { ok: false, action: "hibernate", retryUntil } cuando aplica; el caller
7
+ // puede ignorarlo (fallback a long standby capped) hasta que llegue 0414.
8
+
9
+ import { classifyAgentError, ERROR_CLASS } from "./agent-error-classifier.js";
10
+ import { persistStandby } from "./standby-store.js";
11
+
12
+ const ONE_MIN = 60 * 1000;
13
+
14
+ const ONE_HOUR = 60 * 60 * 1000;
15
+ const DEFAULT_FALLBACK_WAIT_HOURS = 12;
16
+
17
+ export const DEFAULT_RECOVERY_POLICY = Object.freeze({
18
+ hibernateThresholdMs: 5 * ONE_MIN, // > 5 min de espera → hibernar
19
+ longStandbyCapMs: 10 * ONE_MIN, // si el caller no hiberna, espera máx 10 min
20
+ // KJC-TSK-0415: threshold de fallback. Si retryAfter > esto Y el role
21
+ // tiene fallback configurado → switch provider en vez de hibernar.
22
+ fallbackWaitHoursDefault: DEFAULT_FALLBACK_WAIT_HOURS,
23
+ classes: {
24
+ [ERROR_CLASS.RATE_LIMIT_SHORT]: { mode: "standby", maxRetries: 3 },
25
+ [ERROR_CLASS.QUOTA_EXHAUSTED_DAILY]: { mode: "hibernate", maxRetries: 1, fallbackEligible: true },
26
+ [ERROR_CLASS.QUOTA_EXHAUSTED_MONTHLY]: { mode: "hibernate", maxRetries: 1, fallbackEligible: true },
27
+ [ERROR_CLASS.API_DOWN]: { mode: "backoff", maxRetries: 3, baseMs: 5_000, factor: 3, jitterPct: 0.2 },
28
+ [ERROR_CLASS.NETWORK_TIMEOUT]: { mode: "backoff", maxRetries: 3, baseMs: 5_000, factor: 3, jitterPct: 0.2 },
29
+ [ERROR_CLASS.SILENCED]: { mode: "backoff", maxRetries: 2, baseMs: 30_000, factor: 2, jitterPct: 0.15 },
30
+ [ERROR_CLASS.AUTH_FAILED]: { mode: "abort", maxRetries: 0 },
31
+ [ERROR_CLASS.UNKNOWN_FATAL]: { mode: "abort", maxRetries: 0 },
32
+ },
33
+ });
34
+
35
+ function backoffDelay(policyForClass, attempt) {
36
+ const { baseMs = 5_000, factor = 2, jitterPct = 0.1 } = policyForClass;
37
+ const ms = baseMs * factor ** attempt;
38
+ const jitter = ms * jitterPct * (Math.random() * 2 - 1);
39
+ return Math.max(0, Math.round(ms + jitter));
40
+ }
41
+
42
+ function sleep(ms) {
43
+ // En tests (VITEST / NODE_ENV=test) saltamos sleeps reales — los tests que
44
+ // necesitan timing controlado inyectan sleepFn explícito. Esto evita que
45
+ // tests de stages downstream que llaman a withBrainRecovery se cuelguen
46
+ // esperando standby cuando el agente fake devuelve un error recoverable.
47
+ if (process.env.VITEST || process.env.NODE_ENV === "test") return Promise.resolve();
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+
51
+ function emit(emitter, type, eventBase, detail) {
52
+ if (!emitter || typeof emitter.emit !== "function") return;
53
+ try {
54
+ emitter.emit("progress", { ...(eventBase || {}), type, detail });
55
+ } catch { /* nunca bloquees un retry por un emitter mal */ }
56
+ }
57
+
58
+ /**
59
+ * @param {object} args
60
+ * @param {object} args.agent - Agent con runTask({...})
61
+ * @param {object} args.taskArgs - args pasados a agent.runTask
62
+ * @param {string} args.role - "planner" | "coder" | "reviewer" | ...
63
+ * @param {string} [args.provider] - claude|codex|gemini|opencode (default: agent.provider)
64
+ * @param {object} [args.policy] - override DEFAULT_RECOVERY_POLICY
65
+ * @param {object} [args.emitter] - bus de eventos
66
+ * @param {object} [args.eventBase]
67
+ * @param {object} [args.logger]
68
+ * @param {Function} [args.sleepFn] - inyectable para tests
69
+ * @returns {Promise<{ ok: boolean, output?: string, action?: string, recovery?: object, error?: string }>}
70
+ */
71
+ export async function withBrainRecovery({
72
+ agent, taskArgs, role, provider,
73
+ policy = DEFAULT_RECOVERY_POLICY,
74
+ emitter, eventBase, logger,
75
+ sleepFn = sleep,
76
+ // TSK-0414 PR2: si presente, en hibernate persiste { sessionId, planId?, huId?,
77
+ // argv?, ... } a ~/.kj/standby/<sessionId>.json y devuelve standbyFile en
78
+ // el resultado.
79
+ sessionState,
80
+ // KJC-TSK-0415: fallback chain. Si QUOTA_EXHAUSTED_* con retryAfter
81
+ // > maxWaitHours (default 12h) y hay fallback, switch en vez de hibernar.
82
+ fallback = null,
83
+ }) {
84
+ let effectiveAgent = agent;
85
+ let effectiveProvider = provider || agent?.provider || "unknown";
86
+ let effectiveFallback = fallback;
87
+ const attemptsByClass = {};
88
+
89
+ while (true) {
90
+ const result = await effectiveAgent.runTask(taskArgs);
91
+ if (result?.ok) return result;
92
+
93
+ const cls = classifyAgentError({
94
+ provider: effectiveProvider,
95
+ stdout: result?.output || "",
96
+ stderr: result?.error || "",
97
+ exitCode: result?.exitCode ?? null,
98
+ });
99
+ const classPolicy = policy.classes[cls.class] || { mode: "abort", maxRetries: 0 };
100
+ attemptsByClass[cls.class] = (attemptsByClass[cls.class] || 0) + 1;
101
+ const attempt = attemptsByClass[cls.class];
102
+
103
+ emit(emitter, "brain:agent-error", eventBase, { role, class: cls.class, attempt, message: cls.message, provider: effectiveProvider });
104
+ logger?.warn?.(`[brain] ${role} (${effectiveProvider}) → ${cls.class} attempt ${attempt}/${classPolicy.maxRetries}: ${cls.message}`);
105
+
106
+ // ABORT: no recuperable.
107
+ if (classPolicy.mode === "abort" || attempt > classPolicy.maxRetries) {
108
+ emit(emitter, "brain:fatal", eventBase, { role, class: cls.class, message: cls.message });
109
+ return { ok: false, action: "abort", recovery: cls, error: `Brain aborted: ${cls.class} — ${cls.message}` };
110
+ }
111
+
112
+ // KJC-TSK-0415: ¿switch a fallback antes de hibernar?
113
+ // Aplica si:
114
+ // - classPolicy.fallbackEligible (QUOTA_EXHAUSTED_*)
115
+ // - hay fallback configurado
116
+ // - retryAfter excede maxWaitHours del fallback (default 12h)
117
+ if (classPolicy.fallbackEligible && effectiveFallback?.agent && cls.retryAfter) {
118
+ const maxWaitMs = (effectiveFallback.maxWaitHours ?? policy.fallbackWaitHoursDefault ?? DEFAULT_FALLBACK_WAIT_HOURS) * ONE_HOUR;
119
+ if (cls.retryAfter > maxWaitMs) {
120
+ const from = effectiveProvider;
121
+ const to = effectiveFallback.provider || effectiveFallback.agent.provider || "unknown";
122
+ emit(emitter, "brain:fallback-switched", eventBase, { role, from, to, class: cls.class, retryAfter: cls.retryAfter, maxWaitMs });
123
+ logger?.info?.(`[brain] ${role}: fallback ${from} → ${to} (retryAfter ${Math.round(cls.retryAfter/3600000)}h > maxWait ${maxWaitMs/3600000}h)`);
124
+ // Switch al fallback. Reset attempts para que el nuevo provider
125
+ // pueda fallar/retry independientemente. Fallback puede tener su
126
+ // propio fallback en cadena.
127
+ effectiveAgent = effectiveFallback.agent;
128
+ effectiveProvider = to;
129
+ effectiveFallback = effectiveFallback.fallback || null;
130
+ for (const k of Object.keys(attemptsByClass)) attemptsByClass[k] = 0;
131
+ continue;
132
+ }
133
+ }
134
+
135
+ // HIBERNATE: persistir + signal action=hibernate al caller.
136
+ // Si el caller pasó `sessionState` (TSK-0414 PR2), persistimos el JSON
137
+ // automáticamente en ~/.kj/standby/<sessionId>.json. Caller decide qué
138
+ // hacer con action=hibernate (típicamente exit 0 para liberar memoria).
139
+ if (classPolicy.mode === "hibernate" && cls.retryAfter && cls.retryAfter > policy.hibernateThresholdMs) {
140
+ let standbyFile = null;
141
+ if (sessionState && sessionState.sessionId) {
142
+ try {
143
+ standbyFile = persistStandby({
144
+ ...sessionState,
145
+ cooldownUntil: cls.retryUntil,
146
+ reason: cls.class,
147
+ role, provider: effectiveProvider,
148
+ classifierMessage: cls.message,
149
+ });
150
+ logger?.info?.(`[brain] hibernated session ${sessionState.sessionId} → ${standbyFile} (resume at ${cls.retryUntil})`);
151
+ } catch (err) {
152
+ logger?.warn?.(`[brain] failed to persist standby: ${err.message}`);
153
+ }
154
+ }
155
+ emit(emitter, "brain:hibernate-request", eventBase, { role, class: cls.class, retryUntil: cls.retryUntil, retryAfter: cls.retryAfter, standbyFile });
156
+ return { ok: false, action: "hibernate", recovery: cls, standbyFile };
157
+ }
158
+
159
+ // STANDBY: espera hasta cooldownUntil (capado por longStandbyCapMs).
160
+ if (classPolicy.mode === "standby") {
161
+ const waitMs = Math.min(cls.retryAfter || ONE_MIN, policy.longStandbyCapMs);
162
+ emit(emitter, "brain:standby", eventBase, { role, class: cls.class, waitMs, retryUntil: cls.retryUntil });
163
+ await sleepFn(waitMs);
164
+ continue;
165
+ }
166
+
167
+ // BACKOFF exponencial con jitter.
168
+ if (classPolicy.mode === "backoff") {
169
+ const waitMs = backoffDelay(classPolicy, attempt - 1);
170
+ emit(emitter, "brain:backoff", eventBase, { role, class: cls.class, attempt, waitMs });
171
+ await sleepFn(waitMs);
172
+ continue;
173
+ }
174
+
175
+ // Modo desconocido — defensive abort.
176
+ return { ok: false, action: "abort", recovery: cls, error: `Brain: unknown mode '${classPolicy.mode}' for class ${cls.class}` };
177
+ }
178
+ }
@@ -218,7 +218,7 @@ function parseOperations(text) {
218
218
  if (testMatch && inAcceptance) {
219
219
  current.acceptance.push({
220
220
  type: testMatch[1].toLowerCase(),
221
- content: testMatch[2].replace(/^`+|`+$/g, "").trim(),
221
+ content: testMatch[2].replaceAll(/^`+|`+$/g, "").trim(),
222
222
  });
223
223
  }
224
224
  }
@@ -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
  }