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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/agents/claude-agent.js +2 -2
- package/src/agents/codex-agent.js +1 -1
- package/src/audit/agent-readiness.js +1 -1
- package/src/audit/basal-cost.js +4 -4
- package/src/audit/webperf-input.js +1 -1
- package/src/brain/agent-error-classifier.js +101 -0
- package/src/brain/standby-scheduler.js +92 -0
- package/src/brain/standby-store.js +129 -0
- package/src/brain/with-brain-recovery.js +178 -0
- package/src/canvas/parse-md.js +1 -1
- package/src/cli/register-plan.js +29 -0
- package/src/cli/register-standby.js +26 -0
- package/src/cli.js +2 -0
- package/src/commands/architect.js +6 -2
- package/src/commands/audit.js +1 -1
- package/src/commands/discover.js +6 -2
- package/src/commands/init.js +47 -0
- package/src/commands/plan/_shared.js +19 -4
- package/src/commands/plan/fix.js +99 -0
- package/src/commands/plan/generate.js +112 -27
- package/src/commands/plan/index.js +2 -5
- package/src/commands/plan/migrate-result.js +41 -0
- package/src/commands/plan.js +2 -1
- package/src/commands/researcher.js +6 -2
- package/src/commands/run.js +33 -1
- package/src/commands/standby.js +114 -0
- package/src/commands/triage.js +6 -2
- package/src/commands/webperf.js +1 -1
- package/src/config/schema.js +40 -0
- package/src/domains/domain-synthesizer.js +1 -1
- package/src/git/hu-automation.js +2 -2
- package/src/git/hu-snapshot.js +105 -0
- package/src/guards/policy-resolver.js +38 -1
- package/src/hu/auto-generator.js +1 -1
- package/src/hu/lazy-planner.js +6 -1
- package/src/hu/model-router.js +108 -0
- package/src/hu/splitting-generator.js +6 -1
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
- package/src/orchestrator/drivers/run-hu-batch.js +30 -4
- package/src/orchestrator/repair/repair-detector.js +3 -3
- package/src/plan/adr-generator.js +5 -5
- package/src/plan/plan-executor.js +13 -4
- package/src/plan/plan-fixer.js +36 -4
- package/src/plan/plan-hu-ops.js +24 -1
- package/src/plan/plan-id.js +21 -1
- package/src/plan/plan-migrate-result.js +24 -0
- package/src/plan/plan-reviewer.js +40 -3
- package/src/plan/plan-schema.js +128 -1
- package/src/plan/plan-store.js +64 -9
- package/src/plan/plan-structural-pass.js +122 -0
- package/src/plan/plan-validation.js +82 -0
- package/src/plan/tests-synthesizer.js +8 -3
- package/src/prompts/planner.js +27 -1
- package/src/roles/agent-role.js +18 -3
- package/src/roles/perf-role.js +1 -1
- package/src/roles/planner-role.js +10 -3
- package/src/roles/solomon-role.js +10 -4
- package/src/session/journal/decisions-writer.js +1 -1
- package/src/session/journal/iteration-logger.js +1 -1
- package/src/session/journal/summary-writer.js +1 -1
- package/src/skills/addyosmani-catalog.js +1 -1
- package/src/skills/semantic-detector.js +17 -2
- package/src/skills/skills-cache.js +1 -1
- package/src/sonar/api.js +22 -4
- package/src/sonar/config-resolver.js +1 -1
- package/src/sonar/issue-filter.js +110 -0
- package/src/sonar/scanner.js +1 -1
- package/src/utils/cli-progress.js +58 -1
- package/src/utils/derive-project-name-from-cwd.js +3 -3
- package/src/utils/garbage-collector.js +85 -2
- package/src/utils/injection-guard.js +1 -1
- package/src/utils/messages.js +1 -1
- 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
|
+
}
|
package/src/sonar/scanner.js
CHANGED
|
@@ -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)).
|
|
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
|
-
.
|
|
44
|
-
.
|
|
45
|
-
.
|
|
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).
|
|
181
|
+
return text.slice(start, end).replaceAll(/\n/g, "\\n").slice(0, 120);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
function lineAt(text, index) {
|
package/src/utils/messages.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|