karajan-code 3.0.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 +14 -6
- package/docs/README.es.md +1 -1
- package/package.json +8 -2
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +21 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +462 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +273 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +93 -2
- 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 +43 -10
- 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/audit-test-diet.mjs +151 -0
- 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/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- 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/runner.js +10 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +36 -2
- package/src/cli/register-pipeline.js +3 -0
- package/src/cli/register-plan.js +10 -1
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/clean.js +62 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/commands/sync.js +30 -2
- package/src/config/defaults.js +5 -0
- package/src/config/schema.js +2 -0
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- 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/orchestrator/stages/coder-stage.js +8 -2
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- 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/prompts/tool-correctness-judge.js +31 -0
- package/src/rag/vec-store.js +17 -253
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/audit-role.js +11 -1
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sync/apply-canvas-patch.js +55 -0
- 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 -111
- 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/repo-cleaner.js +79 -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
- package/src/utils/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
package/src/rag/vec-store.js
CHANGED
|
@@ -1,253 +1,17 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Surgical stash helper for the TDD discipline gate (KJC-TSK-0398 PR2).
|
|
2
|
+
// Stashes only the source files we want to hide for the "red" run and
|
|
3
|
+
// restores them afterwards. Tracked files use `git stash push -- <paths>`;
|
|
4
|
+
// untracked files (not yet known to git) are moved aside to a private
|
|
5
|
+
// `.karajan/tmp/stash-<id>/` tree because `git stash` would ignore them.
|
|
6
|
+
|
|
7
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { runCommand } from "../utils/process.js";
|
|
11
|
+
|
|
12
|
+
function defaultRunner(cmd, args, opts) {
|
|
13
|
+
return runCommand(cmd, args, opts);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function isTracked(runner, projectDir, file) {
|
|
17
|
+
const res = await runner("git", ["ls-files", "--error-unmatch", "--", file], { cwd: projectDir });
|
|
18
|
+
return res.exitCode === 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function stashRefForToken(runner, projectDir, marker) {
|
|
22
|
+
const res = await runner("git", ["stash", "list"], { cwd: projectDir });
|
|
23
|
+
if (res.exitCode !== 0) return null;
|
|
24
|
+
for (const line of String(res.stdout || "").split("\n")) {
|
|
25
|
+
if (line.includes(marker)) {
|
|
26
|
+
const m = line.match(/^(stash@\{\d+\})/);
|
|
27
|
+
if (m) return m[1];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Move `files` aside so a test run sees them as missing. Returns a token to
|
|
34
|
+
// pass to `restoreFiles()`. Throws on failure with the partial token attached.
|
|
35
|
+
export async function stashFilesAside(files, { projectDir = process.cwd(), runner = defaultRunner, tmpRoot } = {}) {
|
|
36
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
37
|
+
return { trackedStashRef: null, untrackedMoves: [], marker: null };
|
|
38
|
+
}
|
|
39
|
+
const tracked = [];
|
|
40
|
+
const untracked = [];
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
if (await isTracked(runner, projectDir, f)) tracked.push(f);
|
|
43
|
+
else if (existsSync(resolve(projectDir, f))) untracked.push(f);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const marker = `kj-tdd-discipline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
47
|
+
const stashRoot = tmpRoot || join(projectDir, ".karajan", "tmp", marker);
|
|
48
|
+
const untrackedMoves = [];
|
|
49
|
+
let trackedStashRef = null;
|
|
50
|
+
|
|
51
|
+
if (tracked.length > 0) {
|
|
52
|
+
const args = ["stash", "push", "--keep-index", "-m", marker, "--", ...tracked];
|
|
53
|
+
const res = await runner("git", args, { cwd: projectDir });
|
|
54
|
+
if (res.exitCode !== 0) {
|
|
55
|
+
throw new Error(`git stash push failed: ${res.stderr || res.stdout}`);
|
|
56
|
+
}
|
|
57
|
+
trackedStashRef = await stashRefForToken(runner, projectDir, marker);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const f of untracked) {
|
|
61
|
+
const src = resolve(projectDir, f);
|
|
62
|
+
const dst = join(stashRoot, f);
|
|
63
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
64
|
+
await rename(src, dst);
|
|
65
|
+
untrackedMoves.push({ src, dst });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { trackedStashRef, untrackedMoves, marker, stashRoot };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Reverse a `stashFilesAside` token. Idempotent: a restored token becomes a no-op.
|
|
72
|
+
export async function restoreFiles(token, { projectDir = process.cwd(), runner = defaultRunner } = {}) {
|
|
73
|
+
if (!token || (token.trackedStashRef == null && token.untrackedMoves.length === 0)) return;
|
|
74
|
+
|
|
75
|
+
for (const { src, dst } of token.untrackedMoves) {
|
|
76
|
+
if (existsSync(dst)) {
|
|
77
|
+
await mkdir(dirname(src), { recursive: true });
|
|
78
|
+
await rename(dst, src);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (token.stashRoot && existsSync(token.stashRoot)) {
|
|
82
|
+
await rm(token.stashRoot, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (token.trackedStashRef) {
|
|
86
|
+
const res = await runner("git", ["stash", "pop", token.trackedStashRef], { cwd: projectDir });
|
|
87
|
+
if (res.exitCode !== 0) {
|
|
88
|
+
throw new Error(`git stash pop failed: ${res.stderr || res.stdout}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
token.trackedStashRef = null;
|
|
92
|
+
token.untrackedMoves = [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Run `body` with `files` stashed aside, always restoring them — even when
|
|
96
|
+
// `body` throws. The TDD discipline stage wraps its "red" run with this.
|
|
97
|
+
export async function withStashedFiles(files, body, opts = {}) {
|
|
98
|
+
const token = await stashFilesAside(files, opts);
|
|
99
|
+
try {
|
|
100
|
+
return await body(token);
|
|
101
|
+
} finally {
|
|
102
|
+
await restoreFiles(token, opts).catch(() => { /* swallow: surface body error */ });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// TDD discipline gate (KJC-TSK-0398 PR1).
|
|
2
|
+
// PR1 ships the pure decision logic with injected runTests/stashFiles
|
|
3
|
+
// helpers. PR2 ships the real surgical stash. PR3 wires the gate into
|
|
4
|
+
// the pipeline behind `development.require_red_then_green`.
|
|
5
|
+
|
|
6
|
+
const PARSERS = {
|
|
7
|
+
vitest: parseVitestFailures,
|
|
8
|
+
jest: parseVitestFailures,
|
|
9
|
+
pytest: parsePytestFailures,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function parseVitestFailures(output) {
|
|
13
|
+
const failures = new Set();
|
|
14
|
+
for (const line of String(output || "").split("\n")) {
|
|
15
|
+
const m = line.match(/^\s*(?:FAIL|×|✖)\s+(\S+)/);
|
|
16
|
+
if (m) failures.add(m[1].trim());
|
|
17
|
+
}
|
|
18
|
+
return failures;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parsePytestFailures(output) {
|
|
22
|
+
const failures = new Set();
|
|
23
|
+
for (const line of String(output || "").split("\n")) {
|
|
24
|
+
const m = line.match(/^FAILED\s+(\S+)/);
|
|
25
|
+
if (m) failures.add(m[1].trim());
|
|
26
|
+
}
|
|
27
|
+
return failures;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function anyNewTestIn(failures, testFiles) {
|
|
31
|
+
return [...failures].some((f) => testFiles.some((t) => f.includes(t) || t.includes(f)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function verifyTddDiscipline({
|
|
35
|
+
projectDir,
|
|
36
|
+
sourceFiles = [],
|
|
37
|
+
testFiles = [],
|
|
38
|
+
framework = null,
|
|
39
|
+
runTests,
|
|
40
|
+
stashFiles,
|
|
41
|
+
restoreFiles,
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (sourceFiles.length === 0) {
|
|
44
|
+
return { ok: true, reason: "no_source_changes", sourceFiles, testFiles };
|
|
45
|
+
}
|
|
46
|
+
if (testFiles.length === 0) {
|
|
47
|
+
return { ok: true, reason: "no_test_changes", sourceFiles, testFiles };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parser = framework ? PARSERS[framework] : null;
|
|
51
|
+
if (!parser) {
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
reason: "framework_unsupported",
|
|
55
|
+
sourceFiles,
|
|
56
|
+
testFiles,
|
|
57
|
+
warning: `tdd-discipline: framework "${framework || "unknown"}" has no failure parser; gate skipped`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let stashToken = null;
|
|
62
|
+
try {
|
|
63
|
+
stashToken = await stashFiles(sourceFiles);
|
|
64
|
+
const before = await runTests(projectDir);
|
|
65
|
+
if (!anyNewTestIn(parser(before.output), testFiles)) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: "tests_pass_without_impl",
|
|
69
|
+
sourceFiles,
|
|
70
|
+
testFiles,
|
|
71
|
+
message:
|
|
72
|
+
"TDD discipline violation: new tests pass without the implementation. Tests must fail first (red), then pass (green).",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
if (stashToken) await restoreFiles(stashToken);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const after = await runTests(projectDir);
|
|
80
|
+
if (anyNewTestIn(parser(after.output), testFiles)) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: "tests_fail_with_impl",
|
|
84
|
+
sourceFiles,
|
|
85
|
+
testFiles,
|
|
86
|
+
message: "TDD discipline violation: new tests still fail with the implementation present.",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, reason: "red_then_green_verified", sourceFiles, testFiles };
|
|
90
|
+
}
|
package/src/roles/audit-role.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// KJC-TSK-0375 — ToolCorrectnessJudgeRole.
|
|
2
|
+
// Post-iteration LLM judge scoring how well a coder picked its tool calls.
|
|
3
|
+
// PR1 ships role + tests; wiring lives in PR3.
|
|
4
|
+
|
|
5
|
+
import { AgentRole } from "./agent-role.js";
|
|
6
|
+
import { buildToolCorrectnessJudgePrompt } from "../prompts/tool-correctness-judge.js";
|
|
7
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_THRESHOLD = 70;
|
|
10
|
+
|
|
11
|
+
const clampScore = (n) => Number.isFinite(n) ? Math.max(0, Math.min(100, Math.round(n))) : null;
|
|
12
|
+
|
|
13
|
+
const normalizeBreakdown = (b) => Array.isArray(b) ? b.map((x) => ({
|
|
14
|
+
tool: typeof x?.tool === "string" ? x.tool : "unknown",
|
|
15
|
+
args: x?.args && typeof x.args === "object" ? x.args : {},
|
|
16
|
+
correct: Boolean(x?.correct),
|
|
17
|
+
reason: typeof x?.reason === "string" ? x.reason.trim() : "",
|
|
18
|
+
})) : [];
|
|
19
|
+
|
|
20
|
+
export class ToolCorrectnessJudgeRole extends AgentRole {
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
super({ ...opts, name: "tool-correctness-judge" });
|
|
23
|
+
this.threshold = opts?.threshold ?? this.config?.pipeline?.tool_judge?.threshold ?? DEFAULT_THRESHOLD;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
resolveProvider() {
|
|
27
|
+
return this.config?.roles?.["tool-correctness-judge"]?.provider
|
|
28
|
+
|| this.config?.roles?.reviewer?.provider
|
|
29
|
+
|| this.config?.roles?.coder?.provider
|
|
30
|
+
|| "claude";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
extractInput(input) {
|
|
34
|
+
return { ...super.extractInput(input), toolCalls: Array.isArray(input?.toolCalls) ? input.toolCalls : [] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async buildPrompt({ task, toolCalls }) {
|
|
38
|
+
return { prompt: buildToolCorrectnessJudgePrompt({ task, toolCalls, instructions: this.instructions }) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
parseOutput(raw) { return extractFirstJson(raw); }
|
|
42
|
+
|
|
43
|
+
buildSuccessResult(parsed, provider) {
|
|
44
|
+
const score = clampScore(parsed?.score);
|
|
45
|
+
return {
|
|
46
|
+
score,
|
|
47
|
+
summary: typeof parsed?.summary === "string" ? parsed.summary.trim() : "",
|
|
48
|
+
breakdown: normalizeBreakdown(parsed?.breakdown),
|
|
49
|
+
threshold: this.threshold,
|
|
50
|
+
lowQuality: score !== null && score < this.threshold,
|
|
51
|
+
provider,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
buildSummary(parsed) {
|
|
56
|
+
const score = clampScore(parsed?.score);
|
|
57
|
+
if (score === null) return "Tool-judge: unparseable score";
|
|
58
|
+
return `Tool-judge: ${score}/100 (${score < this.threshold ? "LOW" : "OK"})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Empty tool-call list = nothing to judge. Skip the LLM and report a
|
|
62
|
+
// neutral score so an absent transcript is never read as a failure.
|
|
63
|
+
async execute(input) {
|
|
64
|
+
const extracted = this.extractInput(input);
|
|
65
|
+
if (extracted.toolCalls.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
result: {
|
|
69
|
+
score: null, summary: "no tool calls captured (skipped)", breakdown: [],
|
|
70
|
+
threshold: this.threshold, lowQuality: false, provider: this.resolveProvider(), skipped: true,
|
|
71
|
+
},
|
|
72
|
+
summary: "Tool-judge: skipped (no calls)",
|
|
73
|
+
usage: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return super.execute(extracted);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/session/mutators.js
CHANGED
|
@@ -109,6 +109,10 @@ export function setSonarSummary(session, text) {
|
|
|
109
109
|
session.last_sonar_summary = text;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export function setCoderTranscript(session, text) {
|
|
113
|
+
session.last_coder_transcript = text;
|
|
114
|
+
}
|
|
115
|
+
|
|
112
116
|
export function setSonarIssueSignature(session, sig) {
|
|
113
117
|
session.last_sonar_issue_signature = sig;
|
|
114
118
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj sync --apply — append a drift patch to the Canvas markdown.
|
|
3
|
+
*
|
|
4
|
+
* KJC-TSK-0348. Pure data + I/O: reads the Canvas at `canvasPath`, drops a
|
|
5
|
+
* `.bak` snapshot alongside it, appends an HTML-comment block describing
|
|
6
|
+
* untracked / missing / covered files, and atomically renames a `.tmp`
|
|
7
|
+
* over the target. Comments are inert for the Canvas parser, so the
|
|
8
|
+
* patched file stays structurally valid — the user converts the TODOs
|
|
9
|
+
* into Operations by hand.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { promises as fs } from "node:fs";
|
|
13
|
+
|
|
14
|
+
function tempPath(target) {
|
|
15
|
+
return `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderDriftComment(report) {
|
|
19
|
+
const lines = ["", `<!-- kj sync --apply (${new Date().toISOString()}) — review and convert into Operations as needed. -->`];
|
|
20
|
+
let mutations = 0;
|
|
21
|
+
if (report.untracked?.length) {
|
|
22
|
+
lines.push("<!-- Untracked files (modified, no Operation references them — add one): -->");
|
|
23
|
+
for (const u of report.untracked) { lines.push(`<!-- + ${u.path} -->`); mutations += 1; }
|
|
24
|
+
}
|
|
25
|
+
if (report.missing?.length) {
|
|
26
|
+
lines.push("<!-- Missing files (Operation file_hint points at a file that does not exist): -->");
|
|
27
|
+
for (const m of report.missing) { lines.push(`<!-- ! ${m.path} [op #${(m.operationIndex ?? 0) + 1}] -->`); mutations += 1; }
|
|
28
|
+
}
|
|
29
|
+
if (report.covered?.length) {
|
|
30
|
+
lines.push("<!-- Covered (referenced and modified — consider extending the Approach): -->");
|
|
31
|
+
for (const c of report.covered) lines.push(`<!-- ~ ${c.path} [op #${(c.operationIndex ?? 0) + 1}] -->`);
|
|
32
|
+
}
|
|
33
|
+
lines.push("<!-- /kj sync -->", "");
|
|
34
|
+
return { block: lines.join("\n"), mutations };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function applyCanvasPatch({ canvasPath, report }) {
|
|
38
|
+
if (!canvasPath || typeof canvasPath !== "string") throw new Error("applyCanvasPatch: canvasPath is required");
|
|
39
|
+
if (!report || typeof report !== "object") throw new Error("applyCanvasPatch: report is required");
|
|
40
|
+
const hasDrift = (report.untracked?.length || 0) + (report.missing?.length || 0) > 0;
|
|
41
|
+
if (!hasDrift) return { patchedPath: canvasPath, backupPath: null, mutations: 0, skipped: true };
|
|
42
|
+
|
|
43
|
+
let original;
|
|
44
|
+
try { original = await fs.readFile(canvasPath, "utf-8"); }
|
|
45
|
+
catch (err) { throw new Error(`applyCanvasPatch: cannot read canvas ${canvasPath}: ${err.message}`, { cause: err }); }
|
|
46
|
+
|
|
47
|
+
const backupPath = `${canvasPath}.bak`;
|
|
48
|
+
await fs.writeFile(backupPath, original, "utf-8");
|
|
49
|
+
const { block, mutations } = renderDriftComment(report);
|
|
50
|
+
const trimmed = original.endsWith("\n") ? original : `${original}\n`;
|
|
51
|
+
const tmp = tempPath(canvasPath);
|
|
52
|
+
await fs.writeFile(tmp, `${trimmed}${block}\n`, "utf-8");
|
|
53
|
+
await fs.rename(tmp, canvasPath);
|
|
54
|
+
return { patchedPath: canvasPath, backupPath, mutations };
|
|
55
|
+
}
|