karajan-code 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +12 -4
  2. package/package.json +6 -1
  3. package/packages/hu-board/package.json +3 -1
  4. package/packages/hu-board/public/index.html +1 -0
  5. package/packages/hu-board/public/styles.css +32 -0
  6. package/packages/hu-board/public/utils/board-view.js +21 -1
  7. package/packages/hu-board/public/utils/formatters.js +68 -0
  8. package/packages/hu-board/public/wiki.html +32 -0
  9. package/packages/hu-board/public/wiki.js +53 -0
  10. package/packages/hu-board/src/db.js +137 -0
  11. package/packages/hu-board/src/format.js +124 -0
  12. package/packages/hu-board/src/plan-mutations.js +4 -4
  13. package/packages/hu-board/src/routes/api.js +16 -1
  14. package/packages/hu-board/src/routes/rag.js +1 -1
  15. package/packages/hu-board/src/routes/wiki.js +43 -0
  16. package/packages/hu-board/src/run-tracker.js +1 -1
  17. package/packages/hu-board/src/server.js +4 -2
  18. package/packages/hu-board/src/sync.js +1 -1
  19. package/scripts/git-hooks/post-merge +13 -2
  20. package/src/agents/aider-agent.js +42 -1
  21. package/src/agents/claude-agent.js +14 -3
  22. package/src/agents/codex-agent.js +44 -7
  23. package/src/agents/gemini-agent.js +37 -1
  24. package/src/agents/opencode-agent.js +34 -1
  25. package/src/audit/ai-slop-findings.js +102 -0
  26. package/src/audit/deterministic-summary.js +18 -0
  27. package/src/brain/standby-scheduler.js +10 -92
  28. package/src/brain/standby-store.js +12 -168
  29. package/src/checks/ai-trash.js +34 -0
  30. package/src/checks/qmd.js +41 -0
  31. package/src/checks/squeezr.js +36 -0
  32. package/src/cli/register-meta.js +21 -0
  33. package/src/cli/register-pipeline.js +3 -0
  34. package/src/commands/audit.js +6 -4
  35. package/src/commands/board.js +34 -0
  36. package/src/commands/doctor.js +6 -0
  37. package/src/commands/init.js +73 -8
  38. package/src/commands/qmd.js +86 -0
  39. package/src/commands/resume.js +6 -45
  40. package/src/commands/run.js +6 -50
  41. package/src/git/hu-snapshot.js +10 -105
  42. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +27 -0
  43. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  44. package/src/orchestrator/flow-runner.js +10 -2
  45. package/src/orchestrator/hu-sub-pipeline.js +82 -8
  46. package/src/plan/plan-hu-ops.js +16 -338
  47. package/src/plan/plan-id.js +4 -45
  48. package/src/plan/plan-validation.js +4 -82
  49. package/src/rag/vec-store.js +17 -253
  50. package/src/roles/audit-role.js +11 -1
  51. package/src/session/journal/summary-writer.js +55 -1
  52. package/src/utils/ai-trash.js +48 -0
  53. package/src/utils/atomic-write.js +6 -43
  54. package/src/utils/budget.js +38 -9
  55. package/src/utils/cli-ask-question.js +76 -0
  56. package/src/utils/doc-links.js +2 -0
  57. package/src/utils/os-detect.js +10 -0
  58. package/src/utils/paths.js +14 -123
  59. package/src/utils/port-check.js +6 -45
  60. package/src/utils/process.js +4 -188
  61. package/src/utils/qmd-collection.js +72 -0
  62. package/src/utils/qmd-detect.js +18 -0
  63. package/src/utils/qmd-install.js +37 -0
  64. package/src/utils/run-registry.js +9 -116
  65. package/src/utils/shared-paths.js +12 -41
  66. package/src/utils/squeezr-detect.js +18 -0
  67. package/src/utils/squeezr-install.js +37 -0
  68. package/src/utils/telemetry.js +43 -0
@@ -1,253 +1,17 @@
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 "../utils/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
+ // Shim: vec-store primitives now live in @karajan/core/vec-store so
2
+ // the hu-board workspace can consume them without a relative dep on the
3
+ // CLI src tree. KJC-TSK-0511 PR4.
4
+ export {
5
+ dbPath,
6
+ openVecStore,
7
+ insertChunk,
8
+ searchSimilar,
9
+ projectSlug,
10
+ deleteChunksBySource,
11
+ getLastIndexedCommit,
12
+ setLastIndexedCommit,
13
+ getEmbeddingsByIds,
14
+ findChunkByHash,
15
+ countChunks,
16
+ searchBM25,
17
+ } from "@karajan/core/vec-store";
@@ -9,6 +9,7 @@ import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
9
9
  import { collectCircularDeps } from "../audit/circular-deps.js";
10
10
  import { collectDeadExports } from "../audit/dead-exports.js";
11
11
  import { collectInjectionFindings } from "../audit/injection-findings.js";
12
+ import { collectAiSlop } from "../audit/ai-slop-findings.js";
12
13
 
13
14
  function parseDimensions(dimensionsStr) {
14
15
  if (!dimensionsStr || dimensionsStr === "all") return null;
@@ -53,6 +54,7 @@ export class AuditRole extends AgentRole {
53
54
  const noMadge = typeof input === "object" ? Boolean(input?.noMadge) : false;
54
55
  const noKnip = typeof input === "object" ? Boolean(input?.noKnip) : false;
55
56
  const noInjectionScan = typeof input === "object" ? Boolean(input?.noInjectionScan) : false;
57
+ const noAiSlop = typeof input === "object" ? Boolean(input?.noAiSlop) : false;
56
58
  const projectDir = this.config?.projectDir || process.cwd();
57
59
  let basalCost = null;
58
60
  let growthDelta = null;
@@ -64,6 +66,7 @@ export class AuditRole extends AgentRole {
64
66
  let circularDeps = null;
65
67
  let deadExports = null;
66
68
  let injectionFindings = null;
69
+ let aiSlop = null;
67
70
  try {
68
71
  basalCost = await measureBasalCost(projectDir);
69
72
  const previous = await loadPreviousAudit(projectDir);
@@ -116,7 +119,14 @@ export class AuditRole extends AgentRole {
116
119
  injectionFindings = await collectInjectionFindings(projectDir, this.logger);
117
120
  } catch { /* injection scan is best-effort (KJC-TSK-0498) */ }
118
121
  }
119
- return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings };
122
+ // AI-slop tells (KJC-TSK-0503). Deterministic, offline, regex-based.
123
+ // Local equivalent of Deslopify-style review. Best-effort.
124
+ if (!noAiSlop) {
125
+ try {
126
+ aiSlop = await collectAiSlop(projectDir);
127
+ } catch { /* ai-slop scan is best-effort */ }
128
+ }
129
+ return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings, aiSlop };
120
130
  }
121
131
 
122
132
  /**
@@ -18,7 +18,7 @@ import path from "node:path";
18
18
  * @property {"APPROVED"|"REJECTED"|"PAUSED"|"FAILED"|string} result
19
19
  * @property {number} iterations
20
20
  * @property {number} durationMs
21
- * @property {{ total_cost_usd?: number, total_tokens?: number, breakdown_by_role?: Object }} [budget]
21
+ * @property {{ total_cost_usd?: number, total_tokens?: number, total_cached_tokens?: number, breakdown_by_role?: Object }} [budget]
22
22
  * @property {Record<string, { ok?: boolean, summary?: string }>} [stages]
23
23
  * @property {Array<{ hash?: string, message?: string, date?: string, author?: string }>} [commits]
24
24
  * @property {string[]} [files] - names of other journal files in the same dir
@@ -107,6 +107,55 @@ function renderBreakdownByRole(breakdown) {
107
107
  return ["| Role | Tokens | Cost |", "|---|---|---|", ...rows].join("\n");
108
108
  }
109
109
 
110
+ /**
111
+ * Render the Cache hits section (Φ0-F, KJC-TSK-0524). Shows how many input
112
+ * tokens were served from provider-side prompt caches, per role, plus a
113
+ * conservative dollar savings estimate.
114
+ *
115
+ * Savings formula: cached tokens are billed at ~10% of the input rate (the
116
+ * Anthropic/OpenAI convention). Per role:
117
+ * savings_usd ≈ cost_usd * (cached_tokens / tokens_in) * 0.9
118
+ * The estimate is approximate (output tokens also contribute to cost_usd)
119
+ * and labelled with `~$` so users don't read it as exact.
120
+ *
121
+ * Returns:
122
+ * - null when no budget data is available
123
+ * - "cold run" placeholder when cached == 0 across all roles
124
+ * - Markdown table sorted by cached tokens desc when there is cache activity
125
+ */
126
+ function renderCacheHits(budget) {
127
+ if (!budget) return null;
128
+ const totalCached = Number(budget.total_cached_tokens ?? 0);
129
+ const breakdown = budget.breakdown_by_role || {};
130
+ const entries = Object.entries(breakdown)
131
+ .map(([role, m]) => ({
132
+ role,
133
+ cached: Number(m?.cached_tokens || 0),
134
+ tokens_in: Number(m?.tokens_in || 0),
135
+ cost: Number(m?.total_cost_usd || 0),
136
+ }))
137
+ .filter((e) => e.cached > 0);
138
+
139
+ if (totalCached === 0 && entries.length === 0) {
140
+ return "_Cache hits: 0 tokens (cold run)._";
141
+ }
142
+
143
+ const rows = entries
144
+ .sort((a, b) => b.cached - a.cached)
145
+ .map((e) => {
146
+ const ratio = e.tokens_in > 0 ? (e.cached / e.tokens_in) * 100 : 0;
147
+ const savings = e.tokens_in > 0 ? e.cost * (e.cached / e.tokens_in) * 0.9 : 0;
148
+ return `| \`${esc(e.role)}\` | ${e.cached.toLocaleString("en-US")} | ${ratio.toFixed(1)}% | ~$${savings.toFixed(4)} |`;
149
+ });
150
+ const header = "| Role | Cached tokens | Ratio (cached/in) | Est. savings |\n|---|---|---|---|";
151
+ return [
152
+ `**Total cached**: ${totalCached.toLocaleString("en-US")} tokens`,
153
+ "",
154
+ header,
155
+ ...rows,
156
+ ].join("\n");
157
+ }
158
+
110
159
  /**
111
160
  * Render the Skills section: which process skills (addyosmani) were resolved
112
161
  * for the task, which OpenSkills were installed, and which skills would have
@@ -192,6 +241,11 @@ export function buildSummaryMarkdown(input) {
192
241
  sections.push("", "## Budget breakdown (by role)", "", breakdown);
193
242
  }
194
243
 
244
+ const cacheHitsSection = renderCacheHits(input.budget);
245
+ if (cacheHitsSection) {
246
+ sections.push("", "## Cache hits", "", cacheHitsSection);
247
+ }
248
+
195
249
  const skillsSection = renderSkills(input.skills);
196
250
  if (skillsSection) {
197
251
  sections.push("", "## Skills Used", "", skillsSection);
@@ -0,0 +1,48 @@
1
+ // ai-trash detection + Claude Code hook install (KJC-TSK-0391).
2
+ // `kj-trash` is the binary shipped by the @karajan/ai-trash workspace.
3
+ // detectAiTrash() probes the binary; installAiTrashHook() invokes
4
+ // `kj-trash install --claude-code`, which idempotently patches
5
+ // ~/.claude/settings.json to register the PreToolUse hook.
6
+
7
+ import { runCommand } from "./process.js";
8
+
9
+ /**
10
+ * @returns {Promise<{ available: boolean, version: string|null }>}
11
+ */
12
+ export async function detectAiTrash() {
13
+ try {
14
+ const res = await runCommand("kj-trash", ["--help"]);
15
+ if (res.exitCode === 0 || res.exitCode === 2) {
16
+ return { available: true, version: "0.0.0" };
17
+ }
18
+ return { available: false, version: null };
19
+ } catch {
20
+ return { available: false, version: null };
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Register the kj-trash PreToolUse hook in ~/.claude/settings.json.
26
+ * Idempotent: second run reports "already installed" without rewriting.
27
+ *
28
+ * @param {object} logger - logger with info/warn methods
29
+ * @returns {Promise<{ ok: boolean, mutated: boolean, error: string|null }>}
30
+ */
31
+ export async function installAiTrashHook(logger) {
32
+ logger.info("Registering ai-trash Claude Code hook...");
33
+ try {
34
+ const res = await runCommand("kj-trash", ["install", "--claude-code"], { timeout: 30_000 });
35
+ if (res.exitCode !== 0) {
36
+ const error = (res.stderr || "").trim() || `exit code ${res.exitCode}`;
37
+ logger.warn(`ai-trash hook install failed: ${error}`);
38
+ return { ok: false, mutated: false, error };
39
+ }
40
+ const mutated = !(res.stdout || "").includes("already installed");
41
+ logger.info(mutated ? "ai-trash hook installed." : "ai-trash hook already installed.");
42
+ return { ok: true, mutated, error: null };
43
+ } catch (err) {
44
+ const error = err.message || String(err);
45
+ logger.warn(`ai-trash hook install failed: ${error}`);
46
+ return { ok: false, mutated: false, error };
47
+ }
48
+ }
@@ -1,43 +1,6 @@
1
- // Atomic JSON writes for persistent state (resilience audit, Phase 2).
2
- //
3
- // A plain `writeFile(path, JSON.stringify(...))` truncates the live file
4
- // before the new content lands. If the process is killed mid-write
5
- // (SIGKILL, crash, power loss) the file is left truncated and the only
6
- // good copy is already gone — the plan / session / standby JSON
7
- // silently corrupts.
8
- //
9
- // write-temp + rename fixes it: `rename` is atomic on the same
10
- // filesystem, so a reader sees either the old file or the complete new
11
- // one, never a half-written one. The temp file sits next to the target
12
- // (same dir → same FS) and is uniquely named so concurrent writers do
13
- // not clobber each other's temp. A crash before the rename leaves an
14
- // orphan `.tmp` but never a corrupt target.
15
-
16
- import { writeFileSync, renameSync } from "node:fs";
17
- import { writeFile, rename } from "node:fs/promises";
18
-
19
- function tempPath(target) {
20
- return `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
21
- }
22
-
23
- /**
24
- * Atomically write `obj` as pretty JSON to `target` (synchronous).
25
- * @param {string} target - destination path; its directory must exist
26
- * @param {unknown} obj
27
- */
28
- export function writeJsonAtomicSync(target, obj) {
29
- const tmp = tempPath(target);
30
- writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf-8");
31
- renameSync(tmp, target);
32
- }
33
-
34
- /**
35
- * Atomically write `obj` as pretty JSON to `target` (async).
36
- * @param {string} target - destination path; its directory must exist
37
- * @param {unknown} obj
38
- */
39
- export async function writeJsonAtomic(target, obj) {
40
- const tmp = tempPath(target);
41
- await writeFile(tmp, JSON.stringify(obj, null, 2), "utf-8");
42
- await rename(tmp, target);
43
- }
1
+ // Shim — the real implementation now lives in @karajan/core/atomic-write.
2
+ // Kept here so CLI callers (src/**/*.js) keep importing
3
+ // "#utils/atomic-write.js" without churn during KJC-TSK-0511 (decouple
4
+ // @karajan/hu-board). Will be retired once every consumer migrates to
5
+ // the @karajan/core import.
6
+ export { writeJsonAtomic, writeJsonAtomicSync } from "@karajan/core/atomic-write";
@@ -26,6 +26,13 @@ export function extractUsageMetrics(result, defaultModel = null) {
26
26
  usage?.output_tokens ??
27
27
  usage?.completion_tokens ??
28
28
  0;
29
+ const cached_tokens =
30
+ result?.cached_tokens ??
31
+ usage?.cached_tokens ??
32
+ usage?.cache_read_input_tokens ??
33
+ usage?.prompt_tokens_details?.cached_tokens ??
34
+ usage?.cachedContentTokenCount ??
35
+ 0;
29
36
  const cost_usd =
30
37
  result?.cost_usd ??
31
38
  usage?.cost_usd ??
@@ -58,7 +65,7 @@ export function extractUsageMetrics(result, defaultModel = null) {
58
65
  }
59
66
  }
60
67
 
61
- return { tokens_in: finalTokensIn, tokens_out: finalTokensOut, cost_usd, model, estimated };
68
+ return { tokens_in: finalTokensIn, tokens_out: finalTokensOut, cached_tokens, cost_usd, model, estimated };
62
69
  }
63
70
 
64
71
  function toSafeNumber(value) {
@@ -79,9 +86,10 @@ function normalizeLimit(limit) {
79
86
  }
80
87
 
81
88
  function addToBreakdown(map, key, entry) {
82
- const current = map[key] || { tokens_in: 0, tokens_out: 0, total_tokens: 0, total_cost_usd: 0, count: 0 };
89
+ const current = map[key] || { tokens_in: 0, tokens_out: 0, cached_tokens: 0, total_tokens: 0, total_cost_usd: 0, count: 0 };
83
90
  current.tokens_in += entry.tokens_in;
84
91
  current.tokens_out += entry.tokens_out;
92
+ current.cached_tokens += entry.cached_tokens || 0;
85
93
  current.total_tokens += entry.tokens_in + entry.tokens_out;
86
94
  current.total_cost_usd = roundUsd(current.total_cost_usd + entry.cost_usd);
87
95
  current.count += 1;
@@ -94,9 +102,10 @@ export class BudgetTracker {
94
102
  this.pricing = mergePricing(DEFAULT_MODEL_PRICING, options.pricing || {});
95
103
  }
96
104
 
97
- record({ role, provider, model, tokens_in, tokens_out, cost_usd, duration_ms, stage_index, estimated } = {}) {
105
+ record({ role, provider, model, tokens_in, tokens_out, cached_tokens, cost_usd, duration_ms, stage_index, estimated } = {}) {
98
106
  const safeTokensIn = toSafeNumber(tokens_in);
99
107
  const safeTokensOut = toSafeNumber(tokens_out);
108
+ const safeCached = toSafeNumber(cached_tokens);
100
109
  const hasExplicitCost = cost_usd !== undefined && cost_usd !== null && cost_usd !== "";
101
110
  const modelName = model || provider || null;
102
111
  const computedCost = calculateUsageCostUsd({
@@ -113,6 +122,7 @@ export class BudgetTracker {
113
122
  timestamp: new Date().toISOString(),
114
123
  tokens_in: safeTokensIn,
115
124
  tokens_out: safeTokensOut,
125
+ cached_tokens: safeCached,
116
126
  cost_usd: roundUsd(hasExplicitCost ? cost_usd : computedCost)
117
127
  };
118
128
  if (duration_ms !== undefined && duration_ms !== null) {
@@ -129,19 +139,36 @@ export class BudgetTracker {
129
139
  }
130
140
 
131
141
  total() {
142
+ return this._aggregate(this.entries);
143
+ }
144
+
145
+ _aggregate(entries) {
132
146
  let tokensIn = 0;
133
147
  let tokensOut = 0;
148
+ let cached = 0;
134
149
  let totalCost = 0;
135
- for (const entry of this.entries) {
150
+ for (const entry of entries) {
136
151
  tokensIn += entry.tokens_in;
137
152
  tokensOut += entry.tokens_out;
153
+ cached += entry.cached_tokens || 0;
138
154
  totalCost = roundUsd(totalCost + entry.cost_usd);
139
155
  }
140
- return {
141
- tokens_in: tokensIn,
142
- tokens_out: tokensOut,
143
- cost_usd: totalCost
144
- };
156
+ return { tokens_in: tokensIn, tokens_out: tokensOut, cached_tokens: cached, cost_usd: totalCost };
157
+ }
158
+
159
+ /**
160
+ * Snapshot cursor (Φ0-E). Returns an opaque marker pointing at the current
161
+ * end of the entries log. Pair with `since(cursor)` to compute per-HU deltas
162
+ * without resetting global accumulators (per-HU isolation).
163
+ */
164
+ cursor() {
165
+ return this.entries.length;
166
+ }
167
+
168
+ since(cursor = 0) {
169
+ const start = Number.isFinite(cursor) && cursor >= 0 ? Math.min(cursor, this.entries.length) : 0;
170
+ const slice = this.entries.slice(start);
171
+ return { ...this._aggregate(slice), count: slice.length };
145
172
  }
146
173
 
147
174
  remaining(limit) {
@@ -171,6 +198,7 @@ export class BudgetTracker {
171
198
  const hasEstimates = this.entries.some(e => e.estimated);
172
199
  const result = {
173
200
  total_tokens: totals.tokens_in + totals.tokens_out,
201
+ total_cached_tokens: totals.cached_tokens,
174
202
  total_cost_usd: totals.cost_usd,
175
203
  breakdown_by_role: byRole,
176
204
  entries: [...this.entries],
@@ -191,6 +219,7 @@ export class BudgetTracker {
191
219
  duration_ms: entry.duration_ms ?? null,
192
220
  tokens_in: entry.tokens_in,
193
221
  tokens_out: entry.tokens_out,
222
+ cached_tokens: entry.cached_tokens || 0,
194
223
  cost_usd: entry.cost_usd
195
224
  };
196
225
  if (entry.estimated) item.estimated = true;
@@ -0,0 +1,76 @@
1
+ import readline from "node:readline";
2
+
3
+ /**
4
+ * Shared askQuestion factory used by `kj run` and `kj resume`.
5
+ *
6
+ * Pre-fix (KJC-BUG-0081 round 1), `src/commands/run.js` got the guard
7
+ * but `src/commands/resume.js` kept its own copy without the guard, so
8
+ * `kj resume --yes` in non-TTY mode still hung on the HU Board bridge.
9
+ * Centralising the three paths here means both commands honour the
10
+ * same contract and a future fix only needs to land in one place.
11
+ *
12
+ * Three paths:
13
+ * 1. Interactive TTY: prompt via readline (developer terminal).
14
+ * 2. No TTY + `flags.yes`: caller asked NOT to be prompted → write
15
+ * question + context to stderr, return null so the run aborts
16
+ * fast and loud (CI / scripted / `kj audit --yes` desatendido).
17
+ * 3. No TTY without `flags.yes` (HU Board ▶ Run plan with
18
+ * `stdio=ignore`): publish through the file-based bridge so the
19
+ * board can surface a modal; block until the user answers.
20
+ *
21
+ * @param {object} [opts]
22
+ * @param {string|null} [opts.sessionId]
23
+ * @param {object} [opts.flags]
24
+ * @returns {(question: string, context?: object) => Promise<string|null>}
25
+ */
26
+ export function createCliAskQuestion(opts = {}) {
27
+ const { sessionId = null, flags = {} } = opts;
28
+ return async (question, context) => {
29
+ const stdinReadable = process.stdin && process.stdin.readable !== false;
30
+ const isInteractive = Boolean(process.stdin?.isTTY) && stdinReadable;
31
+ if (!isInteractive) {
32
+ if (flags?.yes) {
33
+ process.stderr.write(
34
+ `\n[non-interactive --yes] Pipeline asked a question that cannot be auto-answered:\n`
35
+ + ` ❓ ${question}\n`
36
+ + (context?.detail ? ` Context: ${JSON.stringify(context.detail)}\n` : "")
37
+ + ` Stopping the session. Re-run without --yes (TTY or HU Board) to answer it,\n`
38
+ + ` or pass --skip-spec-review / a more complete spec so the question never appears.\n`
39
+ );
40
+ return null;
41
+ }
42
+ const { askThroughBoard } = await import("./board-prompt-bridge.js");
43
+ console.log(`\n❓ ${question}`);
44
+ if (context?.detail) {
45
+ console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
46
+ }
47
+ console.log(
48
+ "\n[non-interactive] Routing the prompt to the HU Board.\n"
49
+ + " Open http://localhost:4000 — a modal will appear asking for your answer.\n"
50
+ + " This process is now waiting; closing the board does NOT cancel the run."
51
+ );
52
+ try {
53
+ return await askThroughBoard({ sessionId, question, context });
54
+ } catch (err) {
55
+ console.log(`\n[prompt-bridge] ${err.message} — stopping the session.`);
56
+ return null;
57
+ }
58
+ }
59
+
60
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
61
+ return new Promise((resolve) => {
62
+ console.log(`\n❓ ${question}`);
63
+ if (context?.detail) {
64
+ console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
65
+ }
66
+ rl.question("\n> Your response (or 'stop' to exit): ", (answer) => {
67
+ rl.close();
68
+ if (answer.trim().toLowerCase() === "stop") {
69
+ resolve(null);
70
+ } else {
71
+ resolve(answer.trim());
72
+ }
73
+ });
74
+ });
75
+ };
76
+ }