karajan-code 3.1.0 → 3.2.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 +12 -4
- package/package.json +6 -1
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/index.html +1 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/board-view.js +10 -1
- package/packages/hu-board/public/utils/formatters.js +41 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/db.js +78 -0
- package/packages/hu-board/src/format.js +79 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +16 -1
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +43 -0
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +4 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/git-hooks/post-merge +13 -2
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/brain/standby-scheduler.js +10 -92
- package/src/brain/standby-store.js +12 -168
- package/src/checks/ai-trash.js +34 -0
- package/src/checks/qmd.js +41 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +21 -0
- package/src/cli/register-pipeline.js +3 -0
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/hu-sub-pipeline.js +52 -8
- package/src/plan/plan-hu-ops.js +16 -338
- package/src/plan/plan-id.js +4 -45
- package/src/plan/plan-validation.js +4 -82
- package/src/rag/vec-store.js +17 -253
- package/src/roles/audit-role.js +11 -1
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- package/src/utils/cli-ask-question.js +76 -0
- package/src/utils/doc-links.js +2 -0
- package/src/utils/os-detect.js +10 -0
- package/src/utils/paths.js +14 -123
- package/src/utils/port-check.js +6 -45
- package/src/utils/process.js +4 -188
- package/src/utils/qmd-collection.js +72 -0
- package/src/utils/qmd-detect.js +18 -0
- package/src/utils/qmd-install.js +37 -0
- package/src/utils/run-registry.js +9 -116
- package/src/utils/shared-paths.js +12 -41
- package/src/utils/squeezr-detect.js +18 -0
- package/src/utils/squeezr-install.js +37 -0
|
@@ -10,13 +10,14 @@ import { fullScan, startWatcher } from './sync.js';
|
|
|
10
10
|
import apiRoutes from './routes/api.js';
|
|
11
11
|
import pipelineRoutes from './routes/pipeline.js';
|
|
12
12
|
import ragRoutes from './routes/rag.js';
|
|
13
|
+
import wikiRoutes from './routes/wiki.js';
|
|
13
14
|
import { authMiddleware } from './auth.js';
|
|
14
15
|
import { getOrCreateToken, getTokenPath } from './token-store.js';
|
|
15
16
|
import { reapZombieSessions } from './zombie-reaper.js';
|
|
16
17
|
import { reapZombieHus } from './hu-zombie-reaper.js';
|
|
17
18
|
import { setHuStatus as setHuStatusPlanMutation, setHuFailResult as setHuFailResultPlanMutation } from './plan-mutations.js';
|
|
18
19
|
import { cleanupEphemeralProjects } from './ephemeral-cleaner.js';
|
|
19
|
-
import { findAvailablePort as findAvailablePortBase } from '
|
|
20
|
+
import { findAvailablePort as findAvailablePortBase } from '@karajan/core/port-check';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Path to the PID file the CLI's `kj board start` / `kj board stop`
|
|
@@ -237,7 +238,7 @@ async function main() {
|
|
|
237
238
|
// - cooldownUntil > now → re-programa setTimeout que dispara en cooldown
|
|
238
239
|
// Idempotente (lockfile per session). Cero polling.
|
|
239
240
|
try {
|
|
240
|
-
const { reconcileAll } = await import('
|
|
241
|
+
const { reconcileAll } = await import('@karajan/core/standby-scheduler');
|
|
241
242
|
const { spawn } = await import('node:child_process');
|
|
242
243
|
const { join, dirname } = await import('node:path');
|
|
243
244
|
const { fileURLToPath } = await import('node:url');
|
|
@@ -300,6 +301,7 @@ async function main() {
|
|
|
300
301
|
app.use('/api', buildRateLimiter(), authMiddleware(), apiRoutes);
|
|
301
302
|
app.use('/api/pipeline', authMiddleware(), pipelineRoutes);
|
|
302
303
|
app.use('/api/rag', authMiddleware(), ragRoutes);
|
|
304
|
+
app.use('/api/wiki', authMiddleware(), wikiRoutes);
|
|
303
305
|
|
|
304
306
|
// SPA fallback: serve index.html for non-API, non-static routes
|
|
305
307
|
app.get('/{*splat}', (_req, res) => {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
removeTombstone,
|
|
15
15
|
} from './db.js';
|
|
16
16
|
import { publish as publishEvent } from './event-bus.js';
|
|
17
|
-
import { getSharedPlansDir, SHARED_DIR_NAME, isSharedPath } from '
|
|
17
|
+
import { getSharedPlansDir, SHARED_DIR_NAME, isSharedPath } from '@karajan/core/shared-paths';
|
|
18
18
|
import { readConfig } from './config-yaml.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -3,5 +3,16 @@
|
|
|
3
3
|
# never serves stale code. Installed by `kj rag install-hooks`. The marker
|
|
4
4
|
# in this comment is how the installer tells kj-managed hooks from user hooks.
|
|
5
5
|
set -euo pipefail
|
|
6
|
-
if
|
|
7
|
-
kj rag index --since auto >/dev/null 2>&1 || true
|
|
6
|
+
if command -v kj >/dev/null 2>&1; then
|
|
7
|
+
kj rag index --since auto >/dev/null 2>&1 || true
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
# KJC-TSK-0507 — Refresh the per-project QMD semantic wiki when the merge
|
|
11
|
+
# touched indexable folders (docs/, .reviews/, .karajan/plans/). Background
|
|
12
|
+
# so it never blocks the user's `git pull` / `git merge`.
|
|
13
|
+
if command -v qmd >/dev/null 2>&1; then
|
|
14
|
+
if git diff-tree -r --name-only ORIG_HEAD HEAD 2>/dev/null \
|
|
15
|
+
| grep -qE '^(docs/|\.reviews/|\.karajan/plans/)'; then
|
|
16
|
+
(qmd update >/dev/null 2>&1 || true) &
|
|
17
|
+
fi
|
|
18
|
+
fi
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// AI-slop tells detector — KJC-TSK-0503. Deterministic, offline,
|
|
2
|
+
// regex-based scanner over JS/TS source. Five categories:
|
|
3
|
+
// tautologic-comments, banner-separators, boilerplate-docstrings,
|
|
4
|
+
// magic-fallbacks, redundant-jsdoc. Score 100 = clean.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const FILE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"]);
|
|
10
|
+
const IGNORE_DIRS = new Set([
|
|
11
|
+
"node_modules", "dist", "build", "coverage",
|
|
12
|
+
".git", ".karajan", "public", ".next", ".nuxt", ".vercel", ".cache",
|
|
13
|
+
]);
|
|
14
|
+
const VERB = "(?:returns?|gets?|sets?|fetches?|loads?|saves?|creates?|builds?|makes?|computes?|handles?|checks?|validates?)";
|
|
15
|
+
const LINE_PATTERNS = {
|
|
16
|
+
"banner-separators": /\/[/*]\s*[=*\-_#~]{5,}/,
|
|
17
|
+
"magic-fallbacks": /(\?\?|\|\|)\s*(?:""|''|null|undefined)\s*(?:\)|;|,|$)/,
|
|
18
|
+
"redundant-jsdoc": new RegExp(`@param\\s+\\{[^}]+\\}\\s+(\\w+)\\s+(?:-\\s*)?(?:the\\s+)?\\1\\s+(?:parameter|argument|value)`, "i"),
|
|
19
|
+
};
|
|
20
|
+
const TAUT_RE = new RegExp(`^\\s*//\\s*${VERB}\\s+(?:the\\s+|a\\s+|an\\s+)?(\\w+)\\s*\\.?\\s*$`, "i");
|
|
21
|
+
const DOC_RE = new RegExp(`^\\s*/\\*\\*\\s*${VERB}\\s+(?:the\\s+|a\\s+|an\\s+)?(\\w+)\\s*\\.?\\s*\\*/\\s*$`, "i");
|
|
22
|
+
const DECL_RE = /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+(\w+)/;
|
|
23
|
+
|
|
24
|
+
export const CATEGORIES = Object.freeze([
|
|
25
|
+
"tautologic-comments", "banner-separators", "boilerplate-docstrings",
|
|
26
|
+
"magic-fallbacks", "redundant-jsdoc",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function nextDecl(lines, from) {
|
|
30
|
+
for (let j = from; j < Math.min(from + 3, lines.length); j++) {
|
|
31
|
+
const m = lines[j]?.match(DECL_RE);
|
|
32
|
+
if (m) return m[1];
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const stem = (a, b) => { const al = a.toLowerCase(), bl = b.toLowerCase(); return al === bl || al.includes(bl) || bl.includes(al); };
|
|
37
|
+
|
|
38
|
+
export function scanText(relPath, text) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const lines = text.split(/\r?\n/);
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
const line = lines[i];
|
|
43
|
+
const lineNo = i + 1;
|
|
44
|
+
for (const [category, re] of Object.entries(LINE_PATTERNS)) {
|
|
45
|
+
if (re.test(line)) out.push({ path: relPath, line: lineNo, category, snippet: line.trim().slice(0, 100) });
|
|
46
|
+
}
|
|
47
|
+
const t = line.match(TAUT_RE);
|
|
48
|
+
if (t) {
|
|
49
|
+
const decl = nextDecl(lines, i + 1);
|
|
50
|
+
if (decl && stem(decl, t[1])) out.push({ path: relPath, line: lineNo, category: "tautologic-comments", snippet: line.trim().slice(0, 100) });
|
|
51
|
+
}
|
|
52
|
+
const d = line.match(DOC_RE);
|
|
53
|
+
if (d) {
|
|
54
|
+
const decl = nextDecl(lines, i + 1);
|
|
55
|
+
if (decl && stem(decl, d[1])) out.push({ path: relPath, line: lineNo, category: "boilerplate-docstrings", snippet: line.trim().slice(0, 100) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listSourceFiles(root) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const stack = [root];
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const dir = stack.pop();
|
|
66
|
+
let entries;
|
|
67
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const full = path.join(dir, e.name);
|
|
70
|
+
if (e.isDirectory()) {
|
|
71
|
+
if (IGNORE_DIRS.has(e.name) || e.name.startsWith(".")) continue;
|
|
72
|
+
stack.push(full);
|
|
73
|
+
} else if (FILE_EXTS.has(path.extname(e.name))) out.push(full);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function collectAiSlop(rootDir, opts = {}) {
|
|
80
|
+
const maxLOC = Number.isFinite(opts.maxLOC) ? opts.maxLOC : 200_000;
|
|
81
|
+
const maxWorst = Number.isFinite(opts.maxWorst) ? opts.maxWorst : 10;
|
|
82
|
+
try {
|
|
83
|
+
const findings = [];
|
|
84
|
+
let totalLOC = 0, filesScanned = 0;
|
|
85
|
+
for (const file of listSourceFiles(rootDir)) {
|
|
86
|
+
if (totalLOC >= maxLOC) break;
|
|
87
|
+
let content;
|
|
88
|
+
try { content = fs.readFileSync(file, "utf-8"); } catch { continue; }
|
|
89
|
+
findings.push(...scanText(path.relative(rootDir, file) || file, content));
|
|
90
|
+
totalLOC += content.split(/\r?\n/).length;
|
|
91
|
+
filesScanned++;
|
|
92
|
+
}
|
|
93
|
+
const byCategory = Object.fromEntries(CATEGORIES.map((c) => [c, 0]));
|
|
94
|
+
for (const f of findings) byCategory[f.category]++;
|
|
95
|
+
const density = totalLOC > 0 ? (findings.length / totalLOC) * 100 : 0;
|
|
96
|
+
const score = Math.max(0, Math.min(100, Math.round(100 - density * 100)));
|
|
97
|
+
const worst = findings.slice().sort((a, b) => (a.path === b.path ? a.line - b.line : a.path.localeCompare(b.path))).slice(0, maxWorst);
|
|
98
|
+
return { available: true, filesScanned, totalLOC, total: findings.length, score, byCategory, worst };
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return { available: false, reason: err?.message || String(err) };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -41,11 +41,28 @@ export function formatDeterministicSummary(ctx) {
|
|
|
41
41
|
if (ctx.circularDeps) lines.push(...formatCircularDepsBlock(ctx.circularDeps));
|
|
42
42
|
if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
|
|
43
43
|
if (ctx.injectionFindings) lines.push(...formatInjectionBlock(ctx.injectionFindings));
|
|
44
|
+
if (ctx.aiSlop) lines.push(...formatAiSlopBlock(ctx.aiSlop));
|
|
44
45
|
if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
|
|
45
46
|
|
|
46
47
|
return lines.join("\n");
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
function formatAiSlopBlock(slop) {
|
|
51
|
+
if (!slop.available) return ["### AI-slop tells (deterministic)", `- Status: not available — ${slop.reason || "scan failed"}`, ""];
|
|
52
|
+
const lines = ["### AI-slop tells (deterministic)", `- Files scanned: ${slop.filesScanned ?? 0}`, `- Score: ${slop.score}/100 (100 = clean)`, `- Findings: ${slop.total ?? 0}`];
|
|
53
|
+
if ((slop.total ?? 0) > 0) {
|
|
54
|
+
for (const [cat, n] of Object.entries(slop.byCategory || {})) {
|
|
55
|
+
if (n > 0) lines.push(` - ${cat}: ${n}`);
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(slop.worst) && slop.worst.length > 0) {
|
|
58
|
+
lines.push(" - Worst offenders:");
|
|
59
|
+
for (const f of slop.worst) lines.push(` - ${f.path}:${f.line} [${f.category}] ${f.snippet}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
lines.push("");
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
function formatInjectionBlock(inj) {
|
|
50
67
|
if (!inj.available) return ["### Prompt-injection scan", `- Status: not available — ${inj.reason || "scan failed"}`, ""];
|
|
51
68
|
const lines = ["### Prompt-injection scan (specs / domain / onboarding)", `- Files scanned: ${inj.scanned ?? 0}`, `- Findings: ${inj.total ?? 0}`];
|
|
@@ -278,5 +295,6 @@ export function deterministicContextHasFindings(ctx) {
|
|
|
278
295
|
if (ctx.circularDeps?.available && (ctx.circularDeps.total ?? 0) > 0) return true;
|
|
279
296
|
if (ctx.deadExports?.available && (ctx.deadExports.total ?? 0) > 0) return true;
|
|
280
297
|
if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
|
|
298
|
+
if (ctx.aiSlop?.available && (ctx.aiSlop.total ?? 0) > 0) return true;
|
|
281
299
|
return false;
|
|
282
300
|
}
|
|
@@ -1,92 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 };
|
|
1
|
+
// Shim de compatibilidad: standby-scheduler vive ahora en @karajan/core.
|
|
2
|
+
// Mantiene la ruta para callers internos del CLI.
|
|
3
|
+
export {
|
|
4
|
+
_resetScheduledForTests,
|
|
5
|
+
scheduleResume,
|
|
6
|
+
cancelScheduled,
|
|
7
|
+
reconcileAll,
|
|
8
|
+
acquireStandbyLock,
|
|
9
|
+
markStandbyDone,
|
|
10
|
+
} from "@karajan/core/standby-scheduler";
|
|
@@ -1,168 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 { writeJsonAtomicSync } from "../utils/atomic-write.js";
|
|
18
|
-
import { resolveHome } from "../utils/paths.js";
|
|
19
|
-
|
|
20
|
-
const FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
|
|
21
|
-
|
|
22
|
-
// Delegates to the unified resolver. PR 3 of KJC-PCS-0047 switched
|
|
23
|
-
// the default to `.karajan` — hibernated sessions now persist under
|
|
24
|
-
// `~/.karajan/standby/`, migrated automatically from the legacy
|
|
25
|
-
// `~/.kj/standby/` on the next `kj` invocation.
|
|
26
|
-
function getKjHome() {
|
|
27
|
-
return resolveHome({ defaultSegment: ".karajan" });
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function standbyDir() {
|
|
31
|
-
return path.join(getKjHome(), "standby");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function standbyDoneDir() {
|
|
35
|
-
return path.join(standbyDir(), "done");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function sanitizeId(sessionId) {
|
|
39
|
-
if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId requerido");
|
|
40
|
-
return sessionId.replaceAll(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Persiste el estado completo de un run hibernado.
|
|
45
|
-
*
|
|
46
|
-
* @param {object} state - shape:
|
|
47
|
-
* { sessionId, planId?, huId?, role, iteration?, prompt, agentConfig,
|
|
48
|
-
* retryCount?, cooldownUntil, reason, snapshot_sha?, outcome_so_far? }
|
|
49
|
-
* @returns {string} path absoluto del JSON escrito
|
|
50
|
-
*/
|
|
51
|
-
export function persistStandby(state) {
|
|
52
|
-
if (!state || !state.sessionId) throw new Error("state.sessionId requerido");
|
|
53
|
-
if (!state.cooldownUntil) throw new Error("state.cooldownUntil requerido");
|
|
54
|
-
const dir = standbyDir();
|
|
55
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
-
const file = path.join(dir, `${sanitizeId(state.sessionId)}.json`);
|
|
57
|
-
const payload = {
|
|
58
|
-
...state,
|
|
59
|
-
createdAt: state.createdAt || new Date().toISOString(),
|
|
60
|
-
persistedAt: new Date().toISOString(),
|
|
61
|
-
};
|
|
62
|
-
writeJsonAtomicSync(file, payload);
|
|
63
|
-
return file;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Only these env vars are carried into the standby snapshot. NEVER
|
|
67
|
-
// persist the whole `process.env` — that would leak API keys and other
|
|
68
|
-
// secrets into ~/.kj/standby/*.json. `kj standby resume` re-spawns with
|
|
69
|
-
// the live env merged on top, so the snapshot only needs what is needed
|
|
70
|
-
// to relaunch from the right place.
|
|
71
|
-
const STANDBY_ENV_ALLOWLIST = ["KJ_HOME", "HOME", "PATH"];
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Builds the `sessionState` object that `withBrainRecovery` persists when
|
|
75
|
-
* a run hibernates. Pulls the session id, the relaunch metadata and an
|
|
76
|
-
* allowlisted env subset so `kj standby resume` can re-spawn the command.
|
|
77
|
-
* @param {object} args
|
|
78
|
-
* @param {object} args.session - the pipeline session ({ id, plan_id? })
|
|
79
|
-
* @param {object} [args.config] - resolved config ({ projectDir?, plan? })
|
|
80
|
-
* @param {string|null} [args.huId]
|
|
81
|
-
* @returns {object|null} sessionState, or null when there is no session id
|
|
82
|
-
*/
|
|
83
|
-
export function buildStandbyState({ session, config = {}, huId = null } = {}) {
|
|
84
|
-
if (!session?.id) return null;
|
|
85
|
-
const env = {};
|
|
86
|
-
for (const key of STANDBY_ENV_ALLOWLIST) {
|
|
87
|
-
if (process.env[key] != null) env[key] = process.env[key];
|
|
88
|
-
}
|
|
89
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
90
|
-
if (key.startsWith("KJ_") && value != null) env[key] = value;
|
|
91
|
-
}
|
|
92
|
-
return {
|
|
93
|
-
sessionId: session.id,
|
|
94
|
-
planId: session.plan_id || config.plan || null,
|
|
95
|
-
huId: huId || null,
|
|
96
|
-
argv: process.argv.slice(2),
|
|
97
|
-
cwd: config.projectDir || process.cwd(),
|
|
98
|
-
env,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* @param {string} sessionId
|
|
104
|
-
* @returns {object|null}
|
|
105
|
-
*/
|
|
106
|
-
export function loadStandby(sessionId) {
|
|
107
|
-
const file = path.join(standbyDir(), `${sanitizeId(sessionId)}.json`);
|
|
108
|
-
if (!fs.existsSync(file)) return null;
|
|
109
|
-
try {
|
|
110
|
-
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
111
|
-
} catch {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Lista todas las sesiones pendientes (no las de done/).
|
|
118
|
-
* @returns {object[]}
|
|
119
|
-
*/
|
|
120
|
-
export function listPendingStandby() {
|
|
121
|
-
const dir = standbyDir();
|
|
122
|
-
if (!fs.existsSync(dir)) return [];
|
|
123
|
-
return fs.readdirSync(dir)
|
|
124
|
-
.filter((f) => FILENAME_RE.test(f))
|
|
125
|
-
.map((f) => {
|
|
126
|
-
try {
|
|
127
|
-
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
|
128
|
-
} catch {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
})
|
|
132
|
-
.filter(Boolean);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Mueve una sesión a done/ — consumible por GC en TSK-0414 PR3.
|
|
137
|
-
* @param {string} sessionId
|
|
138
|
-
* @returns {boolean} true si se movió
|
|
139
|
-
*/
|
|
140
|
-
export function markStandbyDone(sessionId) {
|
|
141
|
-
const id = sanitizeId(sessionId);
|
|
142
|
-
const src = path.join(standbyDir(), `${id}.json`);
|
|
143
|
-
if (!fs.existsSync(src)) return false;
|
|
144
|
-
const doneDir = standbyDoneDir();
|
|
145
|
-
fs.mkdirSync(doneDir, { recursive: true });
|
|
146
|
-
const dst = path.join(doneDir, `${id}-${Date.now()}.json`);
|
|
147
|
-
fs.renameSync(src, dst);
|
|
148
|
-
return true;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Lockfile para evitar dobles spawns de kj resume sobre la misma sesión.
|
|
153
|
-
* Devuelve { acquired: true, release: () => void } o { acquired: false } si ya hay lock.
|
|
154
|
-
*
|
|
155
|
-
* @param {string} sessionId
|
|
156
|
-
* @returns {{ acquired: boolean, release?: Function }}
|
|
157
|
-
*/
|
|
158
|
-
export function acquireStandbyLock(sessionId) {
|
|
159
|
-
const id = sanitizeId(sessionId);
|
|
160
|
-
const lockFile = path.join(standbyDir(), `${id}.lock`);
|
|
161
|
-
try {
|
|
162
|
-
fs.mkdirSync(standbyDir(), { recursive: true });
|
|
163
|
-
fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
164
|
-
return { acquired: true, release: () => { try { fs.unlinkSync(lockFile); } catch { /* */ } } };
|
|
165
|
-
} catch {
|
|
166
|
-
return { acquired: false };
|
|
167
|
-
}
|
|
168
|
-
}
|
|
1
|
+
// Shim de compatibilidad: standby-store vive ahora en @karajan/core.
|
|
2
|
+
// Mantiene la ruta para callers internos del CLI.
|
|
3
|
+
export {
|
|
4
|
+
standbyDir,
|
|
5
|
+
standbyDoneDir,
|
|
6
|
+
persistStandby,
|
|
7
|
+
buildStandbyState,
|
|
8
|
+
loadStandby,
|
|
9
|
+
listPendingStandby,
|
|
10
|
+
markStandbyDone,
|
|
11
|
+
acquireStandbyLock,
|
|
12
|
+
} from "@karajan/core/standby-store";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-trash safety net availability check (KJC-TSK-0391).
|
|
3
|
+
* Surfaces a WARN in `kj doctor` when the `kj-trash` binary is missing,
|
|
4
|
+
* since the PreToolUse hook cannot snapshot destructive ops without it.
|
|
5
|
+
* The remediation is `kj-trash install --claude-code` once the binary is
|
|
6
|
+
* on PATH (the workspace ships it with karajan-code).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { detectAiTrash } from "../utils/ai-trash.js";
|
|
10
|
+
import { STRATEGY } from "./types.js";
|
|
11
|
+
|
|
12
|
+
function createAiTrashCheck() {
|
|
13
|
+
return {
|
|
14
|
+
name: "ai-trash",
|
|
15
|
+
label: "ai-trash safety net",
|
|
16
|
+
strategy: STRATEGY.MANUAL,
|
|
17
|
+
async detect() {
|
|
18
|
+
const res = await detectAiTrash();
|
|
19
|
+
if (res.available) {
|
|
20
|
+
return { ok: true, severity: "info", detail: "kj-trash present — preventive safety net active" };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
severity: "warn",
|
|
25
|
+
detail: "kj-trash not found — destructive ops unprotected",
|
|
26
|
+
fix: "Reinstall karajan-code (kj-trash ships with the workspace) and run `kj-trash install --claude-code`",
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getAiTrashChecks() {
|
|
33
|
+
return [createAiTrashCheck()];
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QMD availability check. Manual strategy — installation
|
|
3
|
+
* depends on the host platform and is handled by getInstallCommand().
|
|
4
|
+
*
|
|
5
|
+
* QMD (Quick Markdown Search) provides semantic search over the
|
|
6
|
+
* user's wiki / docs / notes collections. Karajan ships it as a
|
|
7
|
+
* default integration (mirror of Squeezr): kj init installs it,
|
|
8
|
+
* kj doctor reports its presence.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { runCommand } from "../utils/process.js";
|
|
12
|
+
import { getInstallCommand } from "../utils/os-detect.js";
|
|
13
|
+
import { withDocLink } from "../utils/doc-links.js";
|
|
14
|
+
import { STRATEGY } from "./types.js";
|
|
15
|
+
|
|
16
|
+
function createQmdCheck() {
|
|
17
|
+
return {
|
|
18
|
+
name: "qmd",
|
|
19
|
+
label: "QMD",
|
|
20
|
+
strategy: STRATEGY.MANUAL,
|
|
21
|
+
async detect() {
|
|
22
|
+
try {
|
|
23
|
+
const res = await runCommand("qmd", ["--version"]);
|
|
24
|
+
if (res.exitCode === 0) {
|
|
25
|
+
return { ok: true, severity: "info", detail: `${res.stdout.trim()} — semantic wiki active` };
|
|
26
|
+
}
|
|
27
|
+
} catch { /* not installed */ }
|
|
28
|
+
const installCmd = getInstallCommand("qmd");
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
severity: "warn",
|
|
32
|
+
detail: "Not found — semantic wiki via QMD available",
|
|
33
|
+
fix: withDocLink(`Install: ${installCmd}`, "qmd_install"),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getQmdChecks() {
|
|
40
|
+
return [createQmdCheck()];
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Squeezr availability check. Manual strategy — installation
|
|
3
|
+
* depends on the host platform and is handled by getInstallCommand().
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { runCommand } from "../utils/process.js";
|
|
7
|
+
import { getInstallCommand } from "../utils/os-detect.js";
|
|
8
|
+
import { withDocLink } from "../utils/doc-links.js";
|
|
9
|
+
import { STRATEGY } from "./types.js";
|
|
10
|
+
|
|
11
|
+
function createSqueezrCheck() {
|
|
12
|
+
return {
|
|
13
|
+
name: "squeezr",
|
|
14
|
+
label: "Squeezr",
|
|
15
|
+
strategy: STRATEGY.MANUAL,
|
|
16
|
+
async detect() {
|
|
17
|
+
try {
|
|
18
|
+
const res = await runCommand("squeezr", ["--version"]);
|
|
19
|
+
if (res.exitCode === 0) {
|
|
20
|
+
return { ok: true, severity: "info", detail: `${res.stdout.trim()} — context compression active` };
|
|
21
|
+
}
|
|
22
|
+
} catch { /* not installed */ }
|
|
23
|
+
const installCmd = getInstallCommand("squeezr");
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
severity: "warn",
|
|
27
|
+
detail: "Not found — context compression via Squeezr available",
|
|
28
|
+
fix: withDocLink(`Install: ${installCmd}`, "squeezr_install"),
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getSqueezrChecks() {
|
|
35
|
+
return [createSqueezrCheck()];
|
|
36
|
+
}
|
package/src/cli/register-meta.js
CHANGED
|
@@ -4,6 +4,7 @@ import { researcherCommand } from "../commands/researcher.js";
|
|
|
4
4
|
import { architectCommand } from "../commands/architect.js";
|
|
5
5
|
import { onboardCommand } from "../commands/onboard.js";
|
|
6
6
|
import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
|
|
7
|
+
import { qmdQueryCommand } from "../commands/qmd.js";
|
|
7
8
|
import { ragMcpCommand } from "../commands/rag-mcp.js";
|
|
8
9
|
import { watchStartCommand, watchStopCommand, watchStatusCommand } from "../commands/watch.js";
|
|
9
10
|
import { auditCommand } from "../commands/audit.js";
|
|
@@ -172,6 +173,24 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
172
173
|
await ragMcpCommand();
|
|
173
174
|
});
|
|
174
175
|
|
|
176
|
+
// KJC-TSK-0509 — `kj qmd query` is a thin wrapper around the upstream
|
|
177
|
+
// `qmd` binary that auto-resolves the project's collection (rag = agent
|
|
178
|
+
// context; qmd = human wiki). One subcommand for now; index/add live in
|
|
179
|
+
// `kj init` and `kj rag install-hooks` (post-merge reindex).
|
|
180
|
+
const qmd = program.command("qmd").description("Semantic search over the per-project QMD wiki (docs/, .reviews/, .karajan/plans/)");
|
|
181
|
+
qmd.command("query <text>")
|
|
182
|
+
.description("Run a qmd query against the wiki collection of the current project")
|
|
183
|
+
.option("--collection <name>", "Override the auto-resolved <slug>-<suffix> collection name")
|
|
184
|
+
.option("--scope <name>", "docs (default) | reviews | plans", "docs")
|
|
185
|
+
.option("--top-k <n>", "Number of hits to return (1..50, default 10)", "10")
|
|
186
|
+
.option("--fast", "Skip the LLM reranker (faster, slightly noisier hits)")
|
|
187
|
+
.option("--json", "Emit the raw JSON array for piping to jq")
|
|
188
|
+
.action(async (text, flags) => {
|
|
189
|
+
await withConfig(pkgVersion, "qmd-query", flags, async ({ config, logger }) => {
|
|
190
|
+
await qmdQueryCommand({ text, config, logger, flags });
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
175
194
|
// KJC-TSK-0441 — `kj watch [start|stop|status]` for live RAG re-index.
|
|
176
195
|
const watch = program.command("watch").description("Live RAG re-index daemon");
|
|
177
196
|
watch.command("start").description("Start the watcher daemon").option("--foreground", "Run in foreground (do not detach)").option("--with-sources", "Watch project source files too").action(async (flags) => {
|
|
@@ -197,6 +216,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
197
216
|
.option("--no-osv", "Skip the OSV-Scanner vulnerability collector. Findings are also skipped automatically when osv-scanner is not installed.")
|
|
198
217
|
.option("--no-semgrep", "Skip the Semgrep SAST collector. Findings are also skipped automatically when semgrep is not installed (install via 'pipx install semgrep' or 'brew install semgrep').")
|
|
199
218
|
.option("--no-harness", "Skip the AI Harness Scorecard stage (Docker one-shot). Auto-skipped when Docker is unavailable. Each run is also persisted to .karajan/audit-history.db (KJC-TSK-0472) for diff/trend tracking.")
|
|
219
|
+
.option("--no-ai-slop", "Skip the AI-slop tells collector (deterministic regex scan for tautologic comments, banner separators, boilerplate docstrings, magic fallbacks, redundant JSDoc). KJC-TSK-0503.")
|
|
200
220
|
.option("--trend", "Append a sparkline of the harness score across the last 10 runs (uses .karajan/audit-history.db). KJC-TSK-0473.")
|
|
201
221
|
.option("--report-file <path>", "Write the audit report to disk in addition to stdout. <path> may be a file (extension drives format: .md or .json) or a directory (creates audit-<ISO>.<md|json> inside). $KJ_AUDIT_REPORT_DIR env var is used as default directory if no --report-file is given.")
|
|
202
222
|
.option("--deterministic-only", "Skip the LLM analysis entirely. Print/persist only the deterministic findings (basalCost, sonar, stack, growth-delta, webperf). Zero tokens spent. Compatible with --report-file and --json.")
|
|
@@ -220,6 +240,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
220
240
|
noOsv: flags.osv === false,
|
|
221
241
|
noSemgrep: flags.semgrep === false,
|
|
222
242
|
noHarness: flags.harness === false,
|
|
243
|
+
noAiSlop: flags.aiSlop === false,
|
|
223
244
|
reportFile: flags.reportFile || null,
|
|
224
245
|
deterministicOnly: Boolean(flags.deterministicOnly),
|
|
225
246
|
yes: Boolean(flags.yes),
|
|
@@ -27,6 +27,9 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
27
27
|
.option("--global", "Save config to ~/.karajan/kj.config.yml (skip the scope wizard)")
|
|
28
28
|
.option("--local", "Save config to ./.karajan/kj.config.yml (skip the scope wizard)")
|
|
29
29
|
.option("--no-ollama", "Skip the RAG embedder bootstrap (Ollama-in-Docker). Useful on modest hardware or when an external embedder will be wired manually")
|
|
30
|
+
.option("--no-rtk", "Skip the RTK auto-install (token savings on Bash outputs). Karajan still runs but burns more tokens")
|
|
31
|
+
.option("--no-squeezr", "Skip the Squeezr auto-install (context compression). Karajan still runs but burns more tokens")
|
|
32
|
+
.option("--no-qmd", "Skip the QMD auto-install + collection registration (semantic wiki over docs/, .reviews/ and plans/)")
|
|
30
33
|
.action(async (flags) => {
|
|
31
34
|
await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
|
|
32
35
|
await initCommand({ logger, flags });
|