karajan-code 3.12.1 → 3.12.3

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 (68) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -5
  3. package/packages/hu-board/package.json +1 -1
  4. package/packages/hu-board/src/plan-mutations.js +4 -4
  5. package/packages/hu-board/src/routes/api.js +1 -1
  6. package/packages/hu-board/src/routes/rag.js +1 -1
  7. package/packages/hu-board/src/routes/wiki.js +1 -1
  8. package/packages/hu-board/src/run-tracker.js +1 -1
  9. package/packages/hu-board/src/server.js +2 -2
  10. package/packages/hu-board/src/sync.js +1 -1
  11. package/scripts/build-sea.mjs +23 -1
  12. package/scripts/esbuild-sea.config.mjs +22 -1
  13. package/scripts/verify-pack.mjs +38 -6
  14. package/src/audit/osv-findings.js +1 -1
  15. package/src/brain/standby-scheduler.js +2 -2
  16. package/src/brain/standby-store.js +2 -2
  17. package/src/commands/init.js +4 -3
  18. package/src/commands/onboard.js +2 -1
  19. package/src/git/hu-snapshot.js +2 -2
  20. package/src/orchestrator/config-init.js +2 -1
  21. package/src/plan/plan-hu-ops.js +2 -2
  22. package/src/plan/plan-id.js +2 -2
  23. package/src/plan/plan-validation.js +2 -2
  24. package/src/prompts/prompt-resolver.js +2 -9
  25. package/src/rag/vec-store.js +2 -2
  26. package/src/utils/atomic-write.js +3 -3
  27. package/src/utils/install-hints.js +5 -1
  28. package/src/utils/paths.js +2 -2
  29. package/src/utils/port-check.js +3 -3
  30. package/src/utils/process.js +2 -2
  31. package/src/utils/run-registry.js +2 -2
  32. package/src/utils/shared-paths.js +3 -3
  33. package/src/utils/templates-root.js +61 -0
  34. package/src/utils/update-check.js +78 -15
  35. package/node_modules/@karajan/core/README.md +0 -42
  36. package/node_modules/@karajan/core/package.json +0 -49
  37. package/node_modules/@karajan/core/src/atomic-write.js +0 -43
  38. package/node_modules/@karajan/core/src/cost/index.js +0 -102
  39. package/node_modules/@karajan/core/src/hu-snapshot.js +0 -105
  40. package/node_modules/@karajan/core/src/index.js +0 -18
  41. package/node_modules/@karajan/core/src/paths.js +0 -123
  42. package/node_modules/@karajan/core/src/plan-hu-ops.js +0 -338
  43. package/node_modules/@karajan/core/src/plan-id.js +0 -45
  44. package/node_modules/@karajan/core/src/plan-validation.js +0 -82
  45. package/node_modules/@karajan/core/src/port-check.js +0 -45
  46. package/node_modules/@karajan/core/src/pricing/index.js +0 -64
  47. package/node_modules/@karajan/core/src/pricing/model-pricing.json +0 -28
  48. package/node_modules/@karajan/core/src/process.js +0 -188
  49. package/node_modules/@karajan/core/src/run-registry.js +0 -115
  50. package/node_modules/@karajan/core/src/shared-paths.js +0 -41
  51. package/node_modules/@karajan/core/src/standby-scheduler.js +0 -92
  52. package/node_modules/@karajan/core/src/standby-store.js +0 -168
  53. package/node_modules/@karajan/core/src/vec-store.js +0 -253
  54. package/node_modules/@karajan/core/tests/atomic-write.test.js +0 -30
  55. package/node_modules/@karajan/core/tests/cost/index.test.js +0 -89
  56. package/node_modules/@karajan/core/tests/hu-snapshot.test.js +0 -39
  57. package/node_modules/@karajan/core/tests/paths.test.js +0 -51
  58. package/node_modules/@karajan/core/tests/plan-hu-ops.test.js +0 -68
  59. package/node_modules/@karajan/core/tests/plan-id.test.js +0 -21
  60. package/node_modules/@karajan/core/tests/plan-validation.test.js +0 -39
  61. package/node_modules/@karajan/core/tests/port-check.test.js +0 -47
  62. package/node_modules/@karajan/core/tests/pricing/index.test.js +0 -95
  63. package/node_modules/@karajan/core/tests/process.test.js +0 -22
  64. package/node_modules/@karajan/core/tests/run-registry.test.js +0 -71
  65. package/node_modules/@karajan/core/tests/shared-paths.test.js +0 -30
  66. package/node_modules/@karajan/core/tests/standby.test.js +0 -66
  67. package/node_modules/@karajan/core/tests/vec-store.test.js +0 -98
  68. package/node_modules/@karajan/core/vitest.config.js +0 -12
@@ -1,253 +0,0 @@
1
- // KJC-PCS-0049 Step 1 — Vector store sobre better-sqlite3 + sqlite-vec.
2
- // El RAG indexer (Step 4) escribe; el retriever (Step 5) lee. Una sola
3
- // DB en `~/.karajan/rag.db` por instalación.
4
- import { mkdirSync, existsSync } from "node:fs";
5
- import { dirname, join } from "node:path";
6
- import Database from "better-sqlite3";
7
- import * as sqliteVec from "sqlite-vec";
8
-
9
- import { getKarajanHome } from "./paths.js";
10
-
11
- const DEFAULT_DIM = 768;
12
-
13
- export function dbPath() {
14
- return process.env.KJ_RAG_DB || join(getKarajanHome(), "rag.db");
15
- }
16
-
17
- /**
18
- * Open the rag.db, load the sqlite-vec extension, and ensure the
19
- * chunks + vec_chunks tables exist. Idempotent: a second call against
20
- * the same file is a no-op past the schema creation.
21
- *
22
- * Returns the open Database handle. Caller owns close().
23
- *
24
- * @param {Object} [opts]
25
- * @param {number} [opts.dim=768] — embedding dimension. Must match
26
- * the embedder configured for the project (Step 2). Changing it
27
- * later requires a fresh DB; the constant is captured in the
28
- * virtual table DDL.
29
- */
30
- export function openVecStore({ dim = DEFAULT_DIM, path = dbPath() } = {}) {
31
- if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true });
32
- const db = new Database(path);
33
- sqliteVec.load(db);
34
- db.pragma("journal_mode = WAL");
35
- db.pragma("foreign_keys = ON");
36
- db.exec(`
37
- CREATE TABLE IF NOT EXISTS chunks (
38
- id INTEGER PRIMARY KEY AUTOINCREMENT,
39
- source TEXT NOT NULL,
40
- kind TEXT NOT NULL CHECK (kind IN ('plan', 'code', 'onboarding')),
41
- text TEXT NOT NULL,
42
- metadata TEXT,
43
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
44
- );
45
- CREATE INDEX IF NOT EXISTS chunks_by_source ON chunks(source);
46
- CREATE INDEX IF NOT EXISTS chunks_by_kind ON chunks(kind);
47
- `);
48
- // KJC-TSK-0438 — Project isolation. project_slug = basename of projectDir
49
- // normalized; chunks indexed before the migration carry NULL and remain
50
- // queryable when `--project all` (or no slug detected at query time).
51
- const cols = db.prepare("PRAGMA table_info(chunks)").all().map((c) => c.name);
52
- if (!cols.includes("project_slug")) {
53
- db.exec("ALTER TABLE chunks ADD COLUMN project_slug TEXT;");
54
- db.exec("CREATE INDEX IF NOT EXISTS chunks_by_project ON chunks(project_slug);");
55
- }
56
- // KJC-TSK-0484 — Content-hash dedup. SHA-256 of the chunk text lets the
57
- // indexer skip embedding bodies it has already seen for the same project.
58
- // Older rows keep NULL; the lookup ignores NULLs so they neither dedup
59
- // nor get deduped until they are re-indexed and stamped.
60
- if (!cols.includes("content_hash")) {
61
- db.exec("ALTER TABLE chunks ADD COLUMN content_hash TEXT;");
62
- db.exec("CREATE INDEX IF NOT EXISTS chunks_by_hash ON chunks(content_hash, project_slug);");
63
- }
64
- // KJC-TSK-0455 — Auto-update by commit diff. Per-project metadata tracks
65
- // the last commit whose changes were reflected in the index, so subsequent
66
- // `kj rag index --since auto` invocations (and the post-merge hook / pre-run
67
- // drift check landed in PR2) only re-embed the actual delta instead of the
68
- // whole project.
69
- db.exec(`
70
- CREATE TABLE IF NOT EXISTS vec_store_meta (
71
- project_slug TEXT PRIMARY KEY,
72
- last_indexed_commit TEXT,
73
- last_indexed_at TEXT NOT NULL DEFAULT (datetime('now'))
74
- );
75
- `);
76
- db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(embedding float[${dim}]);`);
77
- // KJC-TSK-0443 — FTS5 keyword index for BM25 hybrid scoring. content='chunks'
78
- // makes it a contentless mirror linked by id (no double storage); triggers
79
- // sync the FTS5 rows on insert/update/delete. Rebuilds idempotently when
80
- // the FTS5 table exists but is empty (e.g. created post-migration on a
81
- // DB with pre-v2.28 chunks).
82
- db.exec(`
83
- CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, content='chunks', content_rowid='id');
84
- CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
85
- INSERT INTO chunks_fts(rowid, text) VALUES (new.id, new.text);
86
- END;
87
- CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
88
- INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES ('delete', old.id, old.text);
89
- END;
90
- CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
91
- INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES ('delete', old.id, old.text);
92
- INSERT INTO chunks_fts(rowid, text) VALUES (new.id, new.text);
93
- END;
94
- `);
95
- const ftsCount = db.prepare("SELECT COUNT(*) AS n FROM chunks_fts").get().n;
96
- const chunksCount = db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n;
97
- if (ftsCount === 0 && chunksCount > 0) {
98
- db.exec("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');");
99
- }
100
- return db;
101
- }
102
-
103
- /**
104
- * Persist one chunk. The chunks.id and vec_chunks.rowid stay in sync
105
- * because the virtual table rowid is set explicitly to the freshly
106
- * minted chunks.id. Returns that id.
107
- */
108
- export function insertChunk(db, { source, kind, text, metadata = null, embedding, project = null, contentHash = null }) {
109
- if (!Array.isArray(embedding) && !ArrayBuffer.isView(embedding)) {
110
- throw new Error("insertChunk: embedding must be an array or typed array of floats");
111
- }
112
- const meta = metadata == null ? null : (typeof metadata === "string" ? metadata : JSON.stringify(metadata));
113
- const ins = db.prepare("INSERT INTO chunks (source, kind, text, metadata, project_slug, content_hash) VALUES (?, ?, ?, ?, ?, ?)");
114
- const info = ins.run(source, kind, text, meta, project, contentHash);
115
- // sqlite-vec's vec0 virtual table requires the rowid to arrive as a BigInt
116
- // even for values that comfortably fit in a Number. Better-sqlite3 returns
117
- // lastInsertRowid as BigInt only when safeIntegers mode is on; force it.
118
- const id = typeof info.lastInsertRowid === "bigint" ? info.lastInsertRowid : BigInt(info.lastInsertRowid);
119
- const buf = embedding instanceof Float32Array ? embedding : Float32Array.from(embedding);
120
- db.prepare("INSERT INTO vec_chunks(rowid, embedding) VALUES (?, ?)").run(id, Buffer.from(buf.buffer));
121
- return Number(id);
122
- }
123
-
124
- /**
125
- * Cosine-ordered nearest-neighbour search. Returns `[{ id, source, kind,
126
- * text, metadata, distance }]` for the topK best matches; lower
127
- * distance = closer. Returns `[]` when the store is empty.
128
- */
129
- export function searchSimilar(db, embedding, topK = 5, { kind = null, project = null, whereSql = "", whereParams = [] } = {}) {
130
- const buf = embedding instanceof Float32Array ? embedding : Float32Array.from(embedding);
131
- const kindClause = kind ? "AND c.kind = ?" : "";
132
- const projectClause = project ? "AND c.project_slug = ?" : "";
133
- const sql = `
134
- SELECT c.id, c.source, c.kind, c.text, c.metadata, c.project_slug, v.distance
135
- FROM vec_chunks v
136
- JOIN chunks c ON c.id = v.rowid
137
- WHERE v.embedding MATCH ? AND k = ? ${kindClause} ${projectClause}${whereSql}
138
- ORDER BY v.distance
139
- `;
140
- const params = [Buffer.from(buf.buffer), topK];
141
- if (kind) params.push(kind);
142
- if (project) params.push(project);
143
- if (whereParams.length) params.push(...whereParams);
144
- const rows = db.prepare(sql).all(...params);
145
- return rows.map((r) => ({ ...r, metadata: r.metadata ? safeParse(r.metadata) : null }));
146
- }
147
-
148
- /**
149
- * Normalize a projectDir into a stable slug used for project isolation.
150
- * Basename → lowercased → non-alphanumeric stripped to dashes.
151
- * KJC-TSK-0438.
152
- */
153
- export function projectSlug(projectDir) {
154
- if (!projectDir) return null;
155
- const base = String(projectDir).replace(/\/+$/, "").split("/").pop();
156
- if (!base) return null;
157
- return base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
158
- }
159
-
160
- function safeParse(s) { try { return JSON.parse(s); } catch { return s; } }
161
-
162
- /**
163
- * Remove every chunk that came from a given source path. Both tables
164
- * are touched explicitly because vec0 virtual tables do not honour
165
- * SQLite foreign-key cascades.
166
- */
167
- export function deleteChunksBySource(db, source) {
168
- const ids = db.prepare("SELECT id FROM chunks WHERE source = ?").all(source).map((r) => r.id);
169
- if (ids.length === 0) return 0;
170
- const placeholders = ids.map(() => "?").join(",");
171
- db.prepare(`DELETE FROM vec_chunks WHERE rowid IN (${placeholders})`).run(...ids);
172
- db.prepare(`DELETE FROM chunks WHERE id IN (${placeholders})`).run(...ids);
173
- return ids.length;
174
- }
175
-
176
- // KJC-TSK-0455 — Per-project commit watermark for `kj rag index --since auto`
177
- // and the post-merge / pre-run drift hooks landing in PR2.
178
- export function getLastIndexedCommit(db, projectSlug) {
179
- if (!projectSlug) return null;
180
- const row = db.prepare("SELECT last_indexed_commit FROM vec_store_meta WHERE project_slug = ?").get(projectSlug);
181
- return row?.last_indexed_commit ?? null;
182
- }
183
-
184
- export function setLastIndexedCommit(db, projectSlug, commit) {
185
- if (!projectSlug || !commit) return;
186
- db.prepare(`
187
- INSERT INTO vec_store_meta (project_slug, last_indexed_commit, last_indexed_at)
188
- VALUES (?, ?, datetime('now'))
189
- ON CONFLICT(project_slug) DO UPDATE SET
190
- last_indexed_commit = excluded.last_indexed_commit,
191
- last_indexed_at = excluded.last_indexed_at
192
- `).run(projectSlug, commit);
193
- }
194
-
195
- // KJC-TSK-0484 PR-B — Bulk fetch embeddings for an explicit set of chunk ids.
196
- // Returns Map<id, Float32Array>. Used by MMR diversification in retriever.js.
197
- export function getEmbeddingsByIds(db, ids) {
198
- const out = new Map();
199
- if (!ids?.length) return out;
200
- const placeholders = ids.map(() => "?").join(",");
201
- const rows = db.prepare(`SELECT rowid AS id, embedding FROM vec_chunks WHERE rowid IN (${placeholders})`).all(...ids);
202
- for (const row of rows) {
203
- if (!row.embedding) continue;
204
- const buf = Buffer.isBuffer(row.embedding) ? row.embedding : Buffer.from(row.embedding);
205
- out.set(Number(row.id), new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
206
- }
207
- return out;
208
- }
209
-
210
- // KJC-TSK-0484 — Lookup an already-indexed chunk by content_hash. Scoped to a
211
- // project so two repos with the same boilerplate file don't cross-pollute.
212
- // Returns `{ id, source }` or null. Pass `project=null` to match NULL slugs.
213
- export function findChunkByHash(db, hash, project = null) {
214
- if (!hash) return null;
215
- const sql = project
216
- ? "SELECT id, source FROM chunks WHERE content_hash = ? AND project_slug = ? LIMIT 1"
217
- : "SELECT id, source FROM chunks WHERE content_hash = ? AND project_slug IS NULL LIMIT 1";
218
- const row = project ? db.prepare(sql).get(hash, project) : db.prepare(sql).get(hash);
219
- return row || null;
220
- }
221
-
222
- export function countChunks(db, { kind = null } = {}) {
223
- if (kind) return db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE kind = ?").get(kind).n;
224
- return db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n;
225
- }
226
-
227
- /**
228
- * KJC-TSK-0443 — BM25 keyword search over chunks_fts. Returns
229
- * [{ id, source, kind, text, metadata, project_slug, bm25 }] ordered by
230
- * relevance (lower bm25 = better in SQLite FTS5). `query` is sanitized for
231
- * the FTS5 grammar: special chars are stripped, words split on whitespace.
232
- */
233
- export function searchBM25(db, queryText, topK = 10, { kind = null, project = null, whereSql = "", whereParams = [] } = {}) {
234
- const cleaned = String(queryText || "").replace(/["'()]/g, " ").split(/\s+/).filter(Boolean).join(" OR ");
235
- if (!cleaned) return [];
236
- const kindClause = kind ? "AND c.kind = ?" : "";
237
- const projectClause = project ? "AND c.project_slug = ?" : "";
238
- const sql = `
239
- SELECT c.id, c.source, c.kind, c.text, c.metadata, c.project_slug, bm25(chunks_fts) AS bm25
240
- FROM chunks_fts JOIN chunks c ON c.id = chunks_fts.rowid
241
- WHERE chunks_fts MATCH ? ${kindClause} ${projectClause}${whereSql}
242
- ORDER BY bm25 LIMIT ?
243
- `;
244
- const params = [cleaned];
245
- if (kind) params.push(kind);
246
- if (project) params.push(project);
247
- if (whereParams.length) params.push(...whereParams);
248
- params.push(topK);
249
- try {
250
- const rows = db.prepare(sql).all(...params);
251
- return rows.map((r) => ({ ...r, metadata: r.metadata ? safeParse(r.metadata) : null }));
252
- } catch { return []; }
253
- }
@@ -1,30 +0,0 @@
1
- // Smoke coverage for the @karajan/core/atomic-write extraction
2
- // (KJC-TSK-0511 PR1). The full behavioural matrix already lives next to
3
- // the CLI; this file just proves the new package entry point works
4
- // end-to-end so a future regression in workspace wiring fails loudly.
5
- import { mkdtemp, readFile } from "node:fs/promises";
6
- import { tmpdir } from "node:os";
7
- import { join } from "node:path";
8
- import { describe, it, expect } from "vitest";
9
-
10
- import { writeJsonAtomic, writeJsonAtomicSync } from "../src/atomic-write.js";
11
-
12
- async function tmpDir() {
13
- return mkdtemp(join(tmpdir(), "core-atomic-"));
14
- }
15
-
16
- describe("@karajan/core/atomic-write", () => {
17
- it("writeJsonAtomic persists pretty JSON and leaves no .tmp", async () => {
18
- const dir = await tmpDir();
19
- const target = join(dir, "plan.json");
20
- await writeJsonAtomic(target, { hu: 42 });
21
- expect(await readFile(target, "utf-8")).toBe('{\n "hu": 42\n}');
22
- });
23
-
24
- it("writeJsonAtomicSync persists pretty JSON synchronously", async () => {
25
- const dir = await tmpDir();
26
- const target = join(dir, "state.json");
27
- writeJsonAtomicSync(target, { ok: true });
28
- expect(await readFile(target, "utf-8")).toBe('{\n "ok": true\n}');
29
- });
30
- });
@@ -1,89 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { aggregateRunCost } from "../../src/cost/index.js";
3
-
4
- describe("aggregateRunCost — happy path", () => {
5
- it("aggregates a single-role single-model run", () => {
6
- const r = aggregateRunCost({
7
- events: [
8
- { role: "coder", model: "claude-opus-4-7", tokensIn: 1_000_000, tokensOut: 100_000 },
9
- ],
10
- });
11
- // 1M * $15 + 0.1M * $75 = 15 + 7.5
12
- expect(r.totalUsd).toBe(22.5);
13
- expect(r.byRole).toEqual({ coder: 22.5 });
14
- expect(r.byModel).toEqual({ "claude-opus-4-7": 22.5 });
15
- expect(r.hasUnknown).toBe(false);
16
- expect(r.currency).toBe("USD");
17
- });
18
-
19
- it("splits cost by role across multiple models and accumulates same-key events", () => {
20
- const r = aggregateRunCost({
21
- events: [
22
- { role: "coder", model: "claude-opus-4-7", tokensIn: 200_000, tokensOut: 50_000 },
23
- { role: "coder", model: "claude-opus-4-7", tokensIn: 100_000, tokensOut: 0 },
24
- { role: "reviewer", model: "gpt-4o", tokensIn: 100_000, tokensOut: 20_000 },
25
- ],
26
- });
27
- // coder: (0.2*15 + 0.05*75) + (0.1*15) = 6.75 + 1.5 = 8.25
28
- // reviewer: 0.1*2.5 + 0.02*10 = 0.45
29
- expect(r.byRole.coder).toBe(8.25);
30
- expect(r.byRole.reviewer).toBe(0.45);
31
- expect(r.totalUsd).toBe(8.7);
32
- expect(Object.keys(r.byModel).sort()).toEqual(["claude-opus-4-7", "gpt-4o"]);
33
- });
34
- });
35
-
36
- describe("aggregateRunCost — unknown models", () => {
37
- it("routes unknown tokens to unknownModelTokens, sorts + dedupes, calls logger", () => {
38
- const calls = [];
39
- const r = aggregateRunCost({
40
- events: [
41
- { role: "coder", model: "zzz-llm", tokensIn: 500, tokensOut: 200 },
42
- { role: "coder", model: "aaa-llm", tokensIn: 100, tokensOut: 100 },
43
- { role: "coder", model: "zzz-llm", tokensIn: 1, tokensOut: 1 },
44
- { role: "reviewer", model: "claude-opus-4-7", tokensIn: 100_000, tokensOut: 10_000 },
45
- ],
46
- logger: (level, msg, ctx) => calls.push({ level, ctx }),
47
- });
48
- expect(r.hasUnknown).toBe(true);
49
- expect(r.unknownModelTokens.tokensIn).toBe(601);
50
- expect(r.unknownModelTokens.tokensOut).toBe(301);
51
- expect(r.unknownModelTokens.models).toEqual(["aaa-llm", "zzz-llm"]);
52
- expect(r.byRole.coder).toBeUndefined();
53
- expect(r.byRole.reviewer).toBeGreaterThan(0);
54
- expect(calls.length).toBe(3);
55
- expect(calls[0].level).toBe("warn");
56
- });
57
- });
58
-
59
- describe("aggregateRunCost — edge cases", () => {
60
- it("returns zero for empty / missing events", () => {
61
- expect(aggregateRunCost({ events: [] }).totalUsd).toBe(0);
62
- expect(aggregateRunCost({}).totalUsd).toBe(0);
63
- });
64
-
65
- it("skips events with zero tokens and coerces invalid fields", () => {
66
- const r = aggregateRunCost({
67
- events: [
68
- { role: "coder", model: "claude-opus-4-7", tokensIn: 0, tokensOut: 0 },
69
- { role: "coder", model: "claude-opus-4-7", tokensIn: NaN, tokensOut: 10_000 },
70
- { role: "", model: "gpt-4o", tokensIn: 10_000, tokensOut: 1_000 },
71
- null,
72
- "not-an-object",
73
- ],
74
- });
75
- // First skipped, second: tokensIn=0, 10k output * 75 = 0.75
76
- expect(r.byRole.coder).toBe(0.75);
77
- expect(r.byRole.unknown).toBeGreaterThan(0);
78
- });
79
-
80
- it("rounds to 4 decimals", () => {
81
- const r = aggregateRunCost({
82
- events: [{ role: "coder", model: "gemini-1.5-flash", tokensIn: 100_000, tokensOut: 100_000 }],
83
- });
84
- // 0.1*0.075 + 0.1*0.30 = 0.0075 + 0.03 = 0.0375
85
- expect(r.totalUsd).toBe(0.0375);
86
- const frac = String(r.totalUsd).split(".")[1] || "";
87
- expect(frac.length).toBeLessThanOrEqual(4);
88
- });
89
- });
@@ -1,39 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import {
3
- snapshotRefForHu, createHuSnapshot, hasHuSnapshot,
4
- restoreHuSnapshot, removeHuSnapshot,
5
- } from "../src/hu-snapshot.js";
6
-
7
- describe("@karajan/core/hu-snapshot", () => {
8
- it("snapshotRefForHu sanitises and prefixes", () => {
9
- expect(snapshotRefForHu("hu_001")).toBe("refs/kj-snapshots/hu_001");
10
- expect(snapshotRefForHu("hu/with..weird:chars")).toBe("refs/kj-snapshots/hu_with__weird_chars");
11
- expect(() => snapshotRefForHu("")).toThrow(/huId/);
12
- });
13
-
14
- it("createHuSnapshot calls rev-parse then update-ref", async () => {
15
- const runner = vi.fn()
16
- .mockResolvedValueOnce({ exitCode: 0, stdout: "abc123\n", stderr: "" })
17
- .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" });
18
- const r = await createHuSnapshot({ projectDir: "/x", huId: "hu_001", runner });
19
- expect(r).toEqual({ ok: true, ref: "refs/kj-snapshots/hu_001", sha: "abc123" });
20
- });
21
-
22
- it("hasHuSnapshot returns sha when ref exists", async () => {
23
- const runner = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "deadbeef\n", stderr: "" });
24
- expect(await hasHuSnapshot({ projectDir: "/x", huId: "h", runner })).toEqual({ ok: true, sha: "deadbeef" });
25
- });
26
-
27
- it("restoreHuSnapshot reports missing snapshot", async () => {
28
- const runner = vi.fn().mockResolvedValue({ exitCode: 1, stdout: "", stderr: "" });
29
- const r = await restoreHuSnapshot({ projectDir: "/x", huId: "missing", runner });
30
- expect(r.ok).toBe(false);
31
- expect(r.error).toMatch(/no encontrado/);
32
- });
33
-
34
- it("removeHuSnapshot is idempotent (git -d returns 0 even if absent)", async () => {
35
- const runner = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
36
- const r = await removeHuSnapshot({ projectDir: "/x", huId: "absent", runner });
37
- expect(r.ok).toBe(true);
38
- });
39
- });
@@ -1,51 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import {
5
- resolveHome,
6
- getKarajanHome,
7
- getSessionRoot,
8
- getRunsDir,
9
- getPromptsDir,
10
- __resetKjHomeWarningForTests,
11
- } from "../src/paths.js";
12
-
13
- describe("@karajan/core/paths", () => {
14
- let originalKarajanHome;
15
- let originalKjHome;
16
-
17
- beforeEach(() => {
18
- originalKarajanHome = process.env.KARAJAN_HOME;
19
- originalKjHome = process.env.KJ_HOME;
20
- delete process.env.KARAJAN_HOME;
21
- delete process.env.KJ_HOME;
22
- __resetKjHomeWarningForTests();
23
- });
24
-
25
- afterEach(() => {
26
- if (originalKarajanHome !== undefined) process.env.KARAJAN_HOME = originalKarajanHome;
27
- if (originalKjHome !== undefined) process.env.KJ_HOME = originalKjHome;
28
- });
29
-
30
- it("KARAJAN_HOME takes precedence", () => {
31
- process.env.KARAJAN_HOME = "/tmp/explicit-home";
32
- expect(resolveHome()).toBe(path.resolve("/tmp/explicit-home"));
33
- });
34
-
35
- it("getKarajanHome defaults to .karajan segment", () => {
36
- process.env.KARAJAN_HOME = "/tmp/root";
37
- expect(getKarajanHome()).toBe(path.resolve("/tmp/root"));
38
- });
39
-
40
- it("derived dirs hang off getKarajanHome", () => {
41
- process.env.KARAJAN_HOME = "/tmp/root";
42
- const home = path.resolve("/tmp/root");
43
- expect(getSessionRoot()).toBe(path.join(home, "sessions"));
44
- expect(getRunsDir()).toBe(path.join(home, "runs"));
45
- expect(getPromptsDir()).toBe(path.join(home, "prompts"));
46
- });
47
-
48
- it("VITEST env auto-isolates to tmpdir", () => {
49
- expect(resolveHome().startsWith(os.tmpdir())).toBe(true);
50
- });
51
- });
@@ -1,68 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- addHu,
4
- removeHu,
5
- updateHu,
6
- updateHuStatus,
7
- certifyAllHus,
8
- assertPlanRunnable,
9
- computePlanOutcome,
10
- } from "../src/plan-hu-ops.js";
11
-
12
- const newPlan = () => ({ planId: "plan-T", hus: [], updatedAt: null });
13
-
14
- describe("@karajan/core/plan-hu-ops", () => {
15
- it("addHu assigns sequential ID + defaults", () => {
16
- const plan = newPlan();
17
- const hu = addHu(plan, { title: "T1" });
18
- expect(hu.id).toBe("hu_plan-T_001");
19
- expect(hu.status).toBe("pending");
20
- expect(plan.hus).toHaveLength(1);
21
- });
22
-
23
- it("removeHu cleans blocked_by + reuse refs", () => {
24
- const plan = newPlan();
25
- addHu(plan, { title: "T1" });
26
- addHu(plan, { title: "T2", blocked_by: ["hu_plan-T_001"], reuse: ["hu_plan-T_001"] });
27
- expect(removeHu(plan, "hu_plan-T_001")).toBe(true);
28
- expect(plan.hus[0].blocked_by).toEqual([]);
29
- expect(plan.hus[0].reuse).toEqual([]);
30
- });
31
-
32
- it("updateHu only writes allowed fields", () => {
33
- const plan = newPlan();
34
- addHu(plan, { title: "T1" });
35
- updateHu(plan, "hu_plan-T_001", { title: "T1!", id: "evil" });
36
- expect(plan.hus[0].title).toBe("T1!");
37
- expect(plan.hus[0].id).toBe("hu_plan-T_001");
38
- });
39
-
40
- it("updateHuStatus + certifyAllHus + assertPlanRunnable", () => {
41
- const plan = newPlan();
42
- addHu(plan, { title: "T1" });
43
- expect(updateHuStatus(plan, "hu_plan-T_001", "in_progress")).toBe(true);
44
- addHu(plan, { title: "T2" });
45
- expect(certifyAllHus(plan)).toBe(1);
46
- expect(plan.status).toBe("ready");
47
- expect(() => assertPlanRunnable(plan, "plan-T")).not.toThrow();
48
- });
49
-
50
- it("assertPlanRunnable throws when no HU certified", () => {
51
- const plan = newPlan();
52
- addHu(plan, { title: "T1" });
53
- expect(() => assertPlanRunnable(plan, "plan-T")).toThrow(/0 HU/);
54
- });
55
-
56
- it("computePlanOutcome rolls up counts + dedupes", () => {
57
- const plan = newPlan();
58
- addHu(plan, { title: "T1" });
59
- addHu(plan, { title: "T2" });
60
- plan.hus[0].outcome = { status: "done", pr_url: "u", blockers: [] };
61
- plan.hus[1].outcome = { status: "failed", pr_url: "u", blockers: ["x"] };
62
- const o = computePlanOutcome(plan, { duration_ms: 1 });
63
- expect(o.status).toBe("partial");
64
- expect(o.counts).toEqual({ done: 1, failed: 1, blocked: 0, pending: 0 });
65
- expect(o.prs).toEqual(["u"]);
66
- expect(o.blockers).toEqual(["x"]);
67
- });
68
- });
@@ -1,21 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { generatePlanId, generateHuId, normaliseAlias } from "../src/plan-id.js";
3
-
4
- describe("@karajan/core/plan-id", () => {
5
- it("generatePlanId returns plan-<ts>-<rand>", () => {
6
- const id = generatePlanId();
7
- expect(id).toMatch(/^plan-\d{14}-[a-z0-9]{4}$/);
8
- });
9
-
10
- it("generateHuId formats sequence zero-padded", () => {
11
- expect(generateHuId("plan-X", 1)).toBe("hu_plan-X_001");
12
- expect(generateHuId("plan-X", 42)).toBe("hu_plan-X_042");
13
- });
14
-
15
- it("normaliseAlias slugifies + caps to 60 chars", () => {
16
- expect(normaliseAlias("Greta App")).toBe("greta-app");
17
- expect(normaliseAlias("Café — Ñoño")).toBe("cafe-nono");
18
- expect(normaliseAlias("")).toBeNull();
19
- expect(normaliseAlias(null)).toBeNull();
20
- });
21
- });
@@ -1,39 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { validateBlockedByChange } from "../src/plan-validation.js";
3
-
4
- const plan = () => ({
5
- hus: [
6
- { id: "a", blocked_by: [] },
7
- { id: "b", blocked_by: ["a"] },
8
- { id: "c", blocked_by: ["b"] },
9
- ],
10
- });
11
-
12
- describe("@karajan/core/plan-validation", () => {
13
- it("ok when the new blocked_by is a valid subset", () => {
14
- expect(validateBlockedByChange(plan(), "c", ["a"]).ok).toBe(true);
15
- });
16
-
17
- it("rejects self-dependency", () => {
18
- const r = validateBlockedByChange(plan(), "a", ["a"]);
19
- expect(r.ok).toBe(false);
20
- expect(r.error).toMatch(/auto-dependencia/);
21
- });
22
-
23
- it("rejects unknown HU reference", () => {
24
- const r = validateBlockedByChange(plan(), "a", ["zzz"]);
25
- expect(r.ok).toBe(false);
26
- expect(r.error).toMatch(/inexistente/);
27
- });
28
-
29
- it("rejects cycle creation", () => {
30
- const r = validateBlockedByChange(plan(), "a", ["c"]);
31
- expect(r.ok).toBe(false);
32
- expect(r.cycle).toBeDefined();
33
- expect(r.error).toMatch(/Ciclo/);
34
- });
35
-
36
- it("rejects non-array blocked_by", () => {
37
- expect(validateBlockedByChange(plan(), "a", "nope").ok).toBe(false);
38
- });
39
- });
@@ -1,47 +0,0 @@
1
- // Smoke coverage for the @karajan/core/port-check extraction
2
- // (KJC-TSK-0511 PR2). Real socket binding — tiny but fast.
3
- import { createServer } from "node:net";
4
- import { describe, it, expect } from "vitest";
5
-
6
- import { isPortAvailable, findAvailablePort } from "../src/port-check.js";
7
-
8
- function occupy() {
9
- return new Promise((resolve) => {
10
- const srv = createServer();
11
- srv.listen(0, () => resolve({ srv, port: srv.address().port }));
12
- });
13
- }
14
-
15
- describe("@karajan/core/port-check", () => {
16
- it("isPortAvailable returns false for a port we already bound", async () => {
17
- const { srv, port } = await occupy();
18
- try {
19
- expect(await isPortAvailable(port)).toBe(false);
20
- } finally {
21
- srv.close();
22
- }
23
- });
24
-
25
- it("findAvailablePort skips busy ports and returns the next free one", async () => {
26
- const { srv, port } = await occupy();
27
- try {
28
- const got = await findAvailablePort(port, 5);
29
- expect(got).toBeGreaterThan(port);
30
- } finally {
31
- srv.close();
32
- }
33
- });
34
-
35
- it("findAvailablePort throws when the range is exhausted", async () => {
36
- const occupied = [];
37
- for (let i = 0; i < 3; i += 1) occupied.push(await occupy());
38
- const ports = occupied.map((o) => o.port).sort((a, b) => a - b);
39
- try {
40
- const gap = ports[2] - ports[0];
41
- if (gap > 2) return;
42
- await expect(findAvailablePort(ports[0], gap + 1)).rejects.toThrow(/No available port/);
43
- } finally {
44
- for (const { srv } of occupied) srv.close();
45
- }
46
- });
47
- });