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
@@ -0,0 +1,110 @@
1
+ // KJC-TSK-0416: pre-filtro determinístico de falsos positivos Sonar.
2
+ //
3
+ // Antes de que los issues Sonar lleguen al coder como feedback (sonar-role)
4
+ // o al auditor (audit), filtramos los que sabemos que son falsos positivos
5
+ // por reglas conocidas. Sin esto el coder quemaba tokens intentando
6
+ // "arreglar" cosas que no están rotas.
7
+ //
8
+ // Dos mecanismos complementarios:
9
+ // 1. Rules estáticas: { rule, filePattern, reason } — match por reglaId
10
+ // + patrón de path. Cargadas por defecto del catálogo de FALSE_POSITIVES
11
+ // abajo + extensibles via config.sonar.false_positives.
12
+ // 2. Inline ignore: `// karajan-sonar-ignore: <ruleId>` en la línea del
13
+ // issue (o la anterior) → suprime ese hit exacto.
14
+
15
+ import fs from "node:fs";
16
+
17
+ // Catálogo built-in de patrones conocidos. Cada entrada:
18
+ // { rule: "javascript:S2699", filePattern: RegExp, reason: "..." }
19
+ // Ampliable via config.sonar.false_positives.
20
+ export const DEFAULT_FALSE_POSITIVES = [
21
+ {
22
+ rule: "javascript:S2699",
23
+ filePattern: /tests\/architecture\//,
24
+ reason: "Tests structurales fallan vía expect(offenders, msg).toEqual([]) — Sonar no detecta el assert con mensaje custom",
25
+ },
26
+ ];
27
+
28
+ /**
29
+ * Comprueba si una línea del file contiene `// karajan-sonar-ignore: <ruleId>`.
30
+ * Acepta la línea exacta del issue o la inmediatamente anterior.
31
+ *
32
+ * @param {string} filePath
33
+ * @param {number} line - 1-indexed
34
+ * @param {string} ruleId
35
+ * @returns {boolean}
36
+ */
37
+ export function hasInlineIgnore(filePath, line, ruleId) {
38
+ if (!filePath || !line || !ruleId) return false;
39
+ if (!fs.existsSync(filePath)) return false;
40
+ try {
41
+ const lines = fs.readFileSync(filePath, "utf8").split("\n");
42
+ const idx = line - 1;
43
+ const candidates = [lines[idx], lines[idx - 1]].filter(Boolean);
44
+ const re = new RegExp(`karajan-sonar-ignore\\s*:\\s*${escapeRegex(ruleId)}\\b`);
45
+ return candidates.some((l) => re.test(l));
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function escapeRegex(s) {
52
+ return String(s).replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ }
54
+
55
+ /**
56
+ * Filtra issues de Sonar separando los reales de los falsos positivos.
57
+ *
58
+ * @param {object[]} issues - array de Sonar issues (shape: { rule, component, line, message, severity, ... })
59
+ * @param {object} [opts]
60
+ * @param {object[]} [opts.extraRules] - rules adicionales del config
61
+ * @param {string} [opts.projectRoot] - para resolver component → ruta absoluta
62
+ * @returns {{ kept: object[], suppressed: object[] }}
63
+ */
64
+ export function filterFalsePositives(issues, opts = {}) {
65
+ if (!Array.isArray(issues)) return { kept: [], suppressed: [] };
66
+ const rules = [...DEFAULT_FALSE_POSITIVES, ...(opts.extraRules || [])].map((r) => ({
67
+ ...r,
68
+ filePattern: r.filePattern instanceof RegExp ? r.filePattern : new RegExp(String(r.filePattern || ".*")),
69
+ }));
70
+ const projectRoot = opts.projectRoot || process.cwd();
71
+ const kept = [];
72
+ const suppressed = [];
73
+
74
+ for (const issue of issues) {
75
+ const path = componentToPath(issue.component);
76
+ let matched = null;
77
+
78
+ // 1. Rules estáticas.
79
+ for (const rule of rules) {
80
+ if (issue.rule === rule.rule && rule.filePattern.test(path)) {
81
+ matched = { rule: rule.rule, path, reason: rule.reason };
82
+ break;
83
+ }
84
+ }
85
+
86
+ // 2. Inline ignore (más caro — solo si la regla estática no matchea).
87
+ if (!matched && issue.rule && issue.line && path) {
88
+ const absPath = path.startsWith("/") ? path : `${projectRoot}/${path}`;
89
+ if (hasInlineIgnore(absPath, issue.line, issue.rule)) {
90
+ matched = { rule: issue.rule, path, reason: "inline `// karajan-sonar-ignore`" };
91
+ }
92
+ }
93
+
94
+ if (matched) {
95
+ suppressed.push({ ...issue, _suppressedBy: matched });
96
+ } else {
97
+ kept.push(issue);
98
+ }
99
+ }
100
+ return { kept, suppressed };
101
+ }
102
+
103
+ /**
104
+ * Sonar reporta component como `<projectKey>:<relativePath>`. Extrae el path.
105
+ */
106
+ function componentToPath(component) {
107
+ if (typeof component !== "string") return "";
108
+ const colonIdx = component.indexOf(":");
109
+ return colonIdx >= 0 ? component.slice(colonIdx + 1) : component;
110
+ }
@@ -164,7 +164,7 @@ async function ensureSonarProjectProperties(cwd = process.cwd()) {
164
164
  } catch {
165
165
  // no package.json or invalid JSON — use defaults
166
166
  }
167
- const projectKey = (pkg.name || path.basename(cwd)).replace(/[^a-zA-Z0-9_.-]/g, "_");
167
+ const projectKey = (pkg.name || path.basename(cwd)).replaceAll(/[^a-zA-Z0-9_.-]/g, "_");
168
168
  const props = [
169
169
  `sonar.projectKey=${projectKey}`,
170
170
  `sonar.projectName=${pkg.name || path.basename(cwd)}`,
@@ -132,14 +132,71 @@ export function createCliProgressReporter(opts = {}) {
132
132
  if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
133
133
  }
134
134
 
135
+ // JSON suppression — cuando el agente emite estructura JSON, la
136
+ // suprimimos por completo SIN marker ("<json suprimido>" confundía
137
+ // tanto como el propio JSON). Si no hay nada útil que decirle al
138
+ // usuario, mejor silencio.
139
+ //
140
+ // Capturamos 4 patrones:
141
+ // 1. Fence multilínea ``` json ... ``` (state in fence hasta cierre).
142
+ // 2. Brace block standalone { ... } o [ ... ] (state hasta brace=0).
143
+ // 3. Línea suelta con ``` o ```json EN CUALQUIER PARTE (incluso
144
+ // "done: ```json" embedded en texto): suprimida + abre fence
145
+ // si no estaba abierto.
146
+ // 4. Línea suelta con shape de propiedad JSON (` "key": ...`,
147
+ // ` },`, ` ],`, ` {`) cuando NO estamos en bloque: heurística
148
+ // de "trozo de JSON suelto" — se suprime, no entra al stderr.
149
+ let inJsonBlock = false;
150
+ let fenceMode = false;
151
+ let braceDepth = 0;
152
+ const FULL_FENCE_RE = /^\s*```(?:json)?\s*$/;
153
+ const HAS_FENCE_RE = /```/;
154
+ const STANDALONE_BRACE_OPEN = /^[\s]*[{[]\s*$/;
155
+ // Trozo de línea que es CLARAMENTE sintaxis JSON aislada:
156
+ // - ` "foo": "bar",` propiedad
157
+ // - ` "foo": 123` propiedad numérica
158
+ // - ` "foo": {` apertura de objeto en valor
159
+ // - ` },` / ` ],` / ` }` / ` ]` cierre con o sin coma
160
+ const JSON_SCRAP_RE =
161
+ /^\s*(?:"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*.+|[}\]][,]?)\s*$/;
135
162
  const onOutput = (event) => {
136
163
  if (!event || typeof event !== "object") return;
137
164
  const raw = typeof event.line === "string" ? event.line : "";
138
165
  if (!raw) return;
139
- // Strip trailing newline — we re-add our own.
140
166
  const line = raw.replace(/\s+$/, "");
141
167
  if (!line) return;
142
168
  lastActivityAt = Date.now();
169
+ // Estado: dentro de bloque ya marcado.
170
+ if (inJsonBlock) {
171
+ if (fenceMode) {
172
+ if (HAS_FENCE_RE.test(line)) { inJsonBlock = false; fenceMode = false; }
173
+ return;
174
+ }
175
+ braceDepth += (line.match(/[{[]/g) || []).length - (line.match(/[}\]]/g) || []).length;
176
+ if (braceDepth <= 0) { inJsonBlock = false; braceDepth = 0; }
177
+ return;
178
+ }
179
+ // Apertura de bloque por fence (línea es SOLO ``` o ```json).
180
+ if (FULL_FENCE_RE.test(line)) {
181
+ inJsonBlock = true; fenceMode = true; braceDepth = 0;
182
+ return;
183
+ }
184
+ // Apertura de bloque por brace standalone.
185
+ if (STANDALONE_BRACE_OPEN.test(line)) {
186
+ inJsonBlock = true; fenceMode = false; braceDepth = 1;
187
+ return;
188
+ }
189
+ // Fence embedded en texto (línea tipo `done: ```json` o `texto ```).
190
+ // Si la fence está sin cerrar en la misma línea → entra al modo.
191
+ if (HAS_FENCE_RE.test(line)) {
192
+ const fences = (line.match(/```/g) || []).length;
193
+ if (fences % 2 === 1) { // impar → queda fence abierto
194
+ inJsonBlock = true; fenceMode = true; braceDepth = 0;
195
+ }
196
+ return; // suprimida en cualquier caso
197
+ }
198
+ // Trozo de JSON suelto sin fence: suprimir silenciosamente.
199
+ if (JSON_SCRAP_RE.test(line)) return;
143
200
  const icon = ICONS[event.kind] || ICONS.text;
144
201
  stream.write(` ${icon} ${line}\n`);
145
202
  };
@@ -40,9 +40,9 @@ function prettifyName(raw) {
40
40
  if (!trimmed) return null;
41
41
  // Insert space at camelCase boundaries, then split on -/_/whitespace.
42
42
  const spaced = trimmed
43
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
44
- .replace(/[-_]+/g, " ")
45
- .replace(/\s+/g, " ")
43
+ .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
44
+ .replaceAll(/[-_]+/g, " ")
45
+ .replaceAll(/\s+/g, " ")
46
46
  .trim();
47
47
  if (!spaced) return null;
48
48
  return spaced
@@ -274,6 +274,82 @@ async function gcHuStories(opts) {
274
274
  return result;
275
275
  }
276
276
 
277
+ // KJC-TSK-0414 PR3: limpia ~/.kj/standby/done/<id>-<ts>.json finalizadas
278
+ // hace > N días. Las pendientes en ~/.kj/standby/<id>.json NUNCA se tocan
279
+ // — son las que esperan resume.
280
+ async function gcStandbyDone(opts) {
281
+ const result = { removed: [], bytesFreed: 0, errors: [] };
282
+ const now = opts.now || new Date();
283
+ const doneDir = path.join(getKjHome(), "standby", "done");
284
+ if (!(await exists(doneDir))) return result;
285
+
286
+ const entries = (await listDir(doneDir)).filter((e) => e.isFile());
287
+ for (const entry of entries) {
288
+ const file = path.join(doneDir, entry.name);
289
+ const stat = await tryStat(file);
290
+ if (!stat) continue;
291
+ const days = ageInDays(stat, now);
292
+ if (days <= opts.standbyDoneRetentionDays) continue;
293
+ try {
294
+ await unlinkOrRm(file, opts.dryRun);
295
+ result.removed.push({ path: file, reason: `standby done > ${opts.standbyDoneRetentionDays}d (${Math.round(days)}d)`, kind: "standby-done" });
296
+ } catch (err) {
297
+ result.errors.push({ path: file, error: err.message });
298
+ }
299
+ }
300
+ return result;
301
+ }
302
+
303
+ // KJC-TSK-0414 PR3: limpia ~/.karajan/audits/<project>/ con last mtime > N días.
304
+ // El usuario reportó decenas de carpetas kj-test-* huérfanas — son audits de
305
+ // runs antiguos cuyo project ya no existe o que llevan meses sin actividad.
306
+ async function gcAudits(opts) {
307
+ const result = { removed: [], bytesFreed: 0, errors: [] };
308
+ const now = opts.now || new Date();
309
+ const auditRoot = path.join(getKarajanHome(), "audits");
310
+ if (!(await exists(auditRoot))) return result;
311
+
312
+ const entries = (await listDir(auditRoot)).filter((e) => e.isDirectory());
313
+ for (const entry of entries) {
314
+ const dir = path.join(auditRoot, entry.name);
315
+ const stat = await tryStat(dir);
316
+ if (!stat) continue;
317
+ const days = ageInDays(stat, now);
318
+ if (days <= opts.auditsRetentionDays) continue;
319
+ try {
320
+ await unlinkOrRm(dir, opts.dryRun);
321
+ result.removed.push({ path: dir, reason: `audit > ${opts.auditsRetentionDays}d sin actividad (${Math.round(days)}d)`, kind: "audit" });
322
+ } catch (err) {
323
+ result.errors.push({ path: dir, error: err.message });
324
+ }
325
+ }
326
+ return result;
327
+ }
328
+
329
+ // KJC-TSK-0414 PR3: ~/.karajan/hu-board-runs/<runId>/ con mtime > N días.
330
+ async function gcHuBoardRuns(opts) {
331
+ const result = { removed: [], bytesFreed: 0, errors: [] };
332
+ const now = opts.now || new Date();
333
+ const root = path.join(getKarajanHome(), "hu-board-runs");
334
+ if (!(await exists(root))) return result;
335
+
336
+ const entries = (await listDir(root)).filter((e) => e.isDirectory());
337
+ for (const entry of entries) {
338
+ const dir = path.join(root, entry.name);
339
+ const stat = await tryStat(dir);
340
+ if (!stat) continue;
341
+ const days = ageInDays(stat, now);
342
+ if (days <= opts.huBoardRunsRetentionDays) continue;
343
+ try {
344
+ await unlinkOrRm(dir, opts.dryRun);
345
+ result.removed.push({ path: dir, reason: `hu-board-run > ${opts.huBoardRunsRetentionDays}d (${Math.round(days)}d)`, kind: "hu-board-run" });
346
+ } catch (err) {
347
+ result.errors.push({ path: dir, error: err.message });
348
+ }
349
+ }
350
+ return result;
351
+ }
352
+
277
353
  function mergeResults(...parts) {
278
354
  const out = { removed: [], bytesFreed: 0, errors: [] };
279
355
  for (const r of parts) {
@@ -290,6 +366,10 @@ function defaultOpts(opts = {}) {
290
366
  draftRetentionDays: opts.draftRetentionDays ?? 60,
291
367
  sessionRetentionDays: opts.sessionRetentionDays ?? 7,
292
368
  huRetentionDays: opts.huRetentionDays ?? 14,
369
+ // KJC-TSK-0414 PR3
370
+ standbyDoneRetentionDays: opts.standbyDoneRetentionDays ?? 7,
371
+ auditsRetentionDays: opts.auditsRetentionDays ?? 30,
372
+ huBoardRunsRetentionDays: opts.huBoardRunsRetentionDays ?? 30,
293
373
  dryRun: Boolean(opts.dryRun),
294
374
  now: opts.now || new Date(),
295
375
  };
@@ -307,12 +387,15 @@ export async function runManualGC(opts = {}) {
307
387
  // stories live in different directory subtrees and don't share state,
308
388
  // so Promise.all is safe and roughly halves end-to-end GC latency on
309
389
  // a populated KJ_HOME.
310
- const [plans, sessions, huStories] = await Promise.all([
390
+ const [plans, sessions, huStories, standbyDone, audits, huBoardRuns] = await Promise.all([
311
391
  gcPlans(o),
312
392
  gcSessions(o),
313
393
  gcHuStories(o),
394
+ gcStandbyDone(o),
395
+ gcAudits(o),
396
+ gcHuBoardRuns(o),
314
397
  ]);
315
- return mergeResults(plans, sessions, huStories);
398
+ return mergeResults(plans, sessions, huStories, standbyDone, audits, huBoardRuns);
316
399
  }
317
400
 
318
401
  /**
@@ -178,7 +178,7 @@ export function scanDiff(diff, opts = {}) {
178
178
  function excerpt(text, index) {
179
179
  const start = Math.max(0, index - 20);
180
180
  const end = Math.min(text.length, index + 100);
181
- return text.slice(start, end).replace(/\n/g, "\\n").slice(0, 120);
181
+ return text.slice(start, end).replaceAll(/\n/g, "\\n").slice(0, 120);
182
182
  }
183
183
 
184
184
  function lineAt(text, index) {
@@ -48,7 +48,7 @@ const MESSAGES = {
48
48
  */
49
49
  export function msg(key, lang = "en", params = {}) {
50
50
  const template = MESSAGES[lang]?.[key] || MESSAGES.en[key] || key;
51
- return template.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
51
+ return template.replaceAll(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
52
52
  }
53
53
 
54
54
  /**
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Run registry — registro persistente de `kj run` activos.
3
+ *
4
+ * KJC-TSK-0396 (extensión): el HU Board necesita ver runs lanzados desde
5
+ * la terminal externa (no solo los lanzados desde el propio board). Para
6
+ * eso, cada `kj run` escribe su PID + planId + huIds + projectDir a un
7
+ * archivo en `~/.karajan/runs/<runId>.json` al arrancar, y lo borra al
8
+ * terminar (success o crash con cleanup vía finally).
9
+ *
10
+ * El board lee ese directorio en startup + cada vez que sirve el endpoint
11
+ * `/api/runs/:planId/active`, descartando los registros con PID muerto
12
+ * (filtrado con `process.kill(pid, 0)`). Resultado: bidireccionalidad
13
+ * total terminal ↔ board.
14
+ *
15
+ * Compartido entre el CLI (src/commands/run.js) y el board
16
+ * (packages/hu-board/src/run-tracker.js). Vive en src/utils/ porque
17
+ * el board ya importa cosas de src/ sin problema.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import os from "node:os";
23
+
24
+ /**
25
+ * Directorio raíz donde viven los registros de run.
26
+ * Honra `KJ_HOME` para tests y setups custom.
27
+ */
28
+ export function runsDir() {
29
+ const home = process.env.KJ_HOME || path.join(os.homedir(), ".karajan");
30
+ return path.join(home, "runs");
31
+ }
32
+
33
+ /**
34
+ * Registra un nuevo run en disco. El runId es derivado del PID + timestamp
35
+ * para ser único por proceso.
36
+ *
37
+ * @param {{ pid: number, planId?: string|null, huIds?: string[]|null, projectDir?: string|null, source?: string }} info
38
+ * @returns {string|null} runId o null si la escritura falló (no bloquea al caller).
39
+ */
40
+ export function registerRun(info = {}) {
41
+ if (!info?.pid) return null;
42
+ try {
43
+ fs.mkdirSync(runsDir(), { recursive: true });
44
+ // Sufijo random para evitar colisiones cuando 2 runs del mismo PID
45
+ // se registran en el mismo ms (raro en CLI, posible en tests).
46
+ const runId = `run-${Date.now()}-${info.pid}-${Math.random().toString(36).slice(2, 8)}`;
47
+ const filePath = path.join(runsDir(), `${runId}.json`);
48
+ const entry = {
49
+ runId,
50
+ pid: info.pid,
51
+ planId: info.planId || null,
52
+ huIds: Array.isArray(info.huIds) ? info.huIds : null,
53
+ projectDir: info.projectDir || null,
54
+ source: info.source || "cli",
55
+ startedAt: new Date().toISOString(),
56
+ };
57
+ fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf8");
58
+ return runId;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Borra el registro de un run. Idempotente — silencia ENOENT.
66
+ *
67
+ * @param {string} runId
68
+ */
69
+ export function unregisterRun(runId) {
70
+ if (!runId) return;
71
+ try {
72
+ fs.unlinkSync(path.join(runsDir(), `${runId}.json`));
73
+ } catch {
74
+ // Idempotente
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Lista todos los runs activos en disco, filtrando los con PID muerto.
80
+ * Los muertos se borran del FS (auto-cleanup defensivo).
81
+ *
82
+ * @param {{ planId?: string }} [filter] - si se pasa planId, devuelve solo los runs de ese plan.
83
+ * @returns {Array<{runId, pid, planId, huIds, projectDir, source, startedAt}>}
84
+ */
85
+ export function listActiveRuns(filter = {}) {
86
+ const dir = runsDir();
87
+ if (!fs.existsSync(dir)) return [];
88
+ const out = [];
89
+ let files;
90
+ try { files = fs.readdirSync(dir); } catch { return []; }
91
+ for (const f of files) {
92
+ if (!f.endsWith(".json")) continue;
93
+ const filePath = path.join(dir, f);
94
+ let entry;
95
+ try { entry = JSON.parse(fs.readFileSync(filePath, "utf8")); }
96
+ catch { try { fs.unlinkSync(filePath); } catch { /* swallow */ } continue; }
97
+ if (!isAlive(entry.pid)) {
98
+ try { fs.unlinkSync(filePath); } catch { /* swallow */ }
99
+ continue;
100
+ }
101
+ if (filter.planId && entry.planId !== filter.planId) continue;
102
+ out.push(entry);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Verifica si un PID sigue vivo. `process.kill(pid, 0)` NO envía
109
+ * señal — solo lanza ESRCH si el proceso no existe.
110
+ */
111
+ function isAlive(pid) {
112
+ if (!pid) return false;
113
+ try { process.kill(pid, 0); return true; }
114
+ catch (err) { return err.code === "EPERM"; }
115
+ }