karajan-code 3.0.0 → 3.1.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 (54) hide show
  1. package/README.md +3 -3
  2. package/docs/README.es.md +1 -1
  3. package/package.json +3 -2
  4. package/packages/hu-board/public/app.js +81 -4025
  5. package/packages/hu-board/public/index.html +20 -0
  6. package/packages/hu-board/public/utils/api.js +139 -0
  7. package/packages/hu-board/public/utils/board-view.js +453 -0
  8. package/packages/hu-board/public/utils/command-launcher.js +213 -0
  9. package/packages/hu-board/public/utils/config-editor.js +181 -0
  10. package/packages/hu-board/public/utils/dashboard-view.js +103 -0
  11. package/packages/hu-board/public/utils/formatters.js +232 -0
  12. package/packages/hu-board/public/utils/graph-view.js +216 -0
  13. package/packages/hu-board/public/utils/hu-actions.js +224 -0
  14. package/packages/hu-board/public/utils/init-listeners.js +180 -0
  15. package/packages/hu-board/public/utils/log-panel.js +222 -0
  16. package/packages/hu-board/public/utils/modals.js +290 -0
  17. package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
  18. package/packages/hu-board/public/utils/preflight-view.js +89 -0
  19. package/packages/hu-board/public/utils/project-actions.js +143 -0
  20. package/packages/hu-board/public/utils/project-picker-view.js +139 -0
  21. package/packages/hu-board/public/utils/run-launcher.js +169 -0
  22. package/packages/hu-board/public/utils/server-push.js +252 -0
  23. package/packages/hu-board/public/utils/sessions-view.js +199 -0
  24. package/packages/hu-board/public/utils/story-detail-view.js +324 -0
  25. package/packages/hu-board/public/utils/story-edit-form.js +271 -0
  26. package/packages/hu-board/src/config-yaml.js +14 -0
  27. package/packages/hu-board/src/db.js +15 -2
  28. package/packages/hu-board/src/routes/api.js +27 -9
  29. package/scripts/audit-test-diet.mjs +151 -0
  30. package/src/audit/harness-scorecard.js +52 -3
  31. package/src/audit/harness-section.js +6 -2
  32. package/src/checks/runner.js +10 -0
  33. package/src/cli/register-meta.js +15 -2
  34. package/src/cli/register-plan.js +10 -1
  35. package/src/commands/clean.js +62 -0
  36. package/src/commands/plan/generate.js +9 -3
  37. package/src/commands/sync.js +30 -2
  38. package/src/config/defaults.js +5 -0
  39. package/src/config/schema.js +2 -0
  40. package/src/orchestrator/config-init.js +1 -0
  41. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
  42. package/src/orchestrator/stages/coder-stage.js +8 -2
  43. package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
  44. package/src/orchestrator/stages/tool-judge-stage.js +53 -0
  45. package/src/prompts/tool-correctness-judge.js +31 -0
  46. package/src/review/git-stash-helper.js +104 -0
  47. package/src/review/tdd-discipline.js +90 -0
  48. package/src/roles/tool-correctness-judge-role.js +78 -0
  49. package/src/session/mutators.js +4 -0
  50. package/src/sync/apply-canvas-patch.js +55 -0
  51. package/src/utils/paths.js +12 -0
  52. package/src/utils/repo-cleaner.js +79 -0
  53. package/src/utils/tool-call-extractor.js +56 -0
  54. package/src/utils/vector-store-orphans.js +89 -0
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
 
@@ -12,6 +13,17 @@ function vitestRoot() {
12
13
  os.tmpdir(),
13
14
  `karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`
14
15
  );
16
+ // KJC-BUG-0075: clean up on clean exit so /tmp doesn't accumulate one
17
+ // dir per fork × run. `process.on('exit')` runs synchronously and only
18
+ // for graceful exits — SIGKILL leaks survive but are caught by the
19
+ // global-setup mtime purge at the start of the next run.
20
+ process.on("exit", () => {
21
+ try {
22
+ fs.rmSync(_vitestRoot, { recursive: true, force: true });
23
+ } catch {
24
+ // best-effort: never crash a test process on cleanup failure
25
+ }
26
+ });
15
27
  return _vitestRoot;
16
28
  }
17
29
 
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Repo-level cleaner — KJC-TSK-0499 PR1. Read-only collector that
3
+ * surfaces stale local artifacts so the user can decide what to drop.
4
+ * Detects: branches merged into a base ref, branches with `[gone]`
5
+ * upstream, top-level `dist/` `coverage/` `logs/` older than N days,
6
+ * `*.tmp` / `*.bak` older than N days. Never spawns destructive ops.
7
+ */
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { execa } from "execa";
11
+
12
+ const DAY_MS = 24 * 60 * 60 * 1000;
13
+ const STALE_DIRS = ["dist", "coverage", "logs"];
14
+ const STALE_FILE_RX = /\.(tmp|bak)$/;
15
+
16
+ async function safeGit(cwd, args) {
17
+ try {
18
+ const { stdout, exitCode } = await execa("git", args, { cwd, reject: false });
19
+ return exitCode === 0 ? stdout : "";
20
+ } catch { return ""; }
21
+ }
22
+
23
+ async function collectBranches(cwd, baseRef) {
24
+ const items = [];
25
+ const baseShort = baseRef.replace(/^origin\//, "");
26
+ const current = (await safeGit(cwd, ["symbolic-ref", "--short", "HEAD"])).trim();
27
+ const merged = await safeGit(cwd, ["branch", "--merged", baseRef, "--format=%(refname:short)"]);
28
+ for (const raw of merged.split("\n")) {
29
+ const name = raw.trim();
30
+ if (!name || name === current || name === baseShort) continue;
31
+ items.push({ kind: "branch", target: name, reason: `merged into ${baseRef}`, command: `git branch -d ${name}` });
32
+ }
33
+ for (const line of (await safeGit(cwd, ["branch", "-vv"])).split("\n")) {
34
+ const m = line.match(/^[\s*]+(\S+)\s+\S+\s+\[[^:]+: gone\]/);
35
+ if (m && m[1] !== current) items.push({ kind: "branch-gone", target: m[1], reason: "upstream gone", command: `git branch -D ${m[1]}` });
36
+ }
37
+ return items;
38
+ }
39
+
40
+ async function ageMs(p) {
41
+ try { return Date.now() - (await fs.stat(p)).mtimeMs; } catch { return -1; }
42
+ }
43
+
44
+ async function collectStale(cwd, maxAgeDays) {
45
+ const cutoff = maxAgeDays * DAY_MS;
46
+ const items = [];
47
+ for (const name of STALE_DIRS) {
48
+ const full = path.join(cwd, name);
49
+ if (await ageMs(full) > cutoff) {
50
+ items.push({ kind: "dir", target: full, reason: `${name}/ older than ${maxAgeDays}d`, command: `rm -rf ${full}` });
51
+ }
52
+ }
53
+ let entries = [];
54
+ try { entries = await fs.readdir(cwd, { withFileTypes: true }); } catch { /* cwd unreadable */ }
55
+ for (const e of entries) {
56
+ if (!e.isFile() || !STALE_FILE_RX.test(e.name)) continue;
57
+ const full = path.join(cwd, e.name);
58
+ if (await ageMs(full) > cutoff) {
59
+ items.push({ kind: "file", target: full, reason: `${path.extname(e.name)} older than ${maxAgeDays}d`, command: `rm -f ${full}` });
60
+ }
61
+ }
62
+ return items;
63
+ }
64
+
65
+ /**
66
+ * Discover repo-level cleanup candidates.
67
+ * @param {object} opts
68
+ * @param {string} [opts.cwd=process.cwd()]
69
+ * @param {number} [opts.maxAgeDays=7]
70
+ * @param {string} [opts.mergedTo="origin/main"]
71
+ * @returns {Promise<{candidates: Array, errors: Array}>}
72
+ */
73
+ export async function collectRepoCandidates({ cwd = process.cwd(), maxAgeDays = 7, mergedTo = "origin/main" } = {}) {
74
+ const candidates = [];
75
+ const errors = [];
76
+ try { candidates.push(...await collectBranches(cwd, mergedTo)); } catch (e) { errors.push({ stage: "branches", error: String(e?.message ?? e) }); }
77
+ try { candidates.push(...await collectStale(cwd, maxAgeDays)); } catch (e) { errors.push({ stage: "stale", error: String(e?.message ?? e) }); }
78
+ return { candidates, errors };
79
+ }
@@ -0,0 +1,56 @@
1
+ // KJC-TSK-0375 PR2 — Extract structured tool calls from an agent transcript.
2
+ //
3
+ // The ToolCorrectnessJudgeRole (PR1) consumes a list of `{tool, args}`
4
+ // objects. The coder writes a raw transcript (provider-dependent format) to
5
+ // disk per iteration. This module bridges the two: parse the transcript,
6
+ // return the ordered list of tool invocations.
7
+ //
8
+ // Provider-agnostic interface; Claude stream-json is the only format
9
+ // implemented today. New providers add a branch below — keep parsers
10
+ // pure (no I/O), so the role tests can mock transcripts trivially.
11
+
12
+ const PROVIDER_PARSERS = {
13
+ claude: parseClaudeStreamJson,
14
+ };
15
+
16
+ /**
17
+ * Extract tool calls from a transcript string.
18
+ *
19
+ * @param {object} args
20
+ * @param {string} args.transcript - raw agent output (stdout/stderr buffer).
21
+ * @param {string} [args.provider] - "claude" (default). Unknown returns [].
22
+ * @returns {Array<{tool:string, args:object}>}
23
+ */
24
+ export function extractToolCalls({ transcript, provider = "claude" } = {}) {
25
+ if (typeof transcript !== "string" || transcript.length === 0) return [];
26
+ const parser = PROVIDER_PARSERS[provider];
27
+ if (!parser) return [];
28
+ return parser(transcript);
29
+ }
30
+
31
+ // Claude emits one JSON event per line. We care about `assistant` events
32
+ // whose `message.content` is an array containing `tool_use` blocks with
33
+ // `{type:"tool_use", name, input}`. Lines that fail to parse, that are
34
+ // not `assistant`, or whose content is not an array are silently skipped:
35
+ // the transcript also carries `system`, `result`, `rate_limit_event`, etc.
36
+ function parseClaudeStreamJson(transcript) {
37
+ const out = [];
38
+ for (const raw of transcript.split("\n")) {
39
+ const line = raw.trim();
40
+ if (!line || line[0] !== "{") continue;
41
+ let obj;
42
+ try { obj = JSON.parse(line); } catch { continue; }
43
+ if (obj?.type !== "assistant") continue;
44
+ const content = obj?.message?.content;
45
+ if (!Array.isArray(content)) continue;
46
+ for (const block of content) {
47
+ if (block?.type === "tool_use" && typeof block.name === "string") {
48
+ out.push({
49
+ tool: block.name,
50
+ args: block.input && typeof block.input === "object" ? block.input : {},
51
+ });
52
+ }
53
+ }
54
+ }
55
+ return out;
56
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * RAG vector-store orphan collector — KJC-TSK-0499 PR2.
3
+ *
4
+ * Enumerates distinct `project_slug` rows in `~/.karajan/rag.db`,
5
+ * augments each with chunk count + last_indexed_{at,commit}, and
6
+ * marks as orphan the slugs whose basename does not resolve to a
7
+ * directory in any of the configured `projectRoots`.
8
+ *
9
+ * Read-only. Returns rows + a copy-paste sqlite3 command per orphan.
10
+ * Deliberate inverse of the RAG indexer: never opens the DB for write.
11
+ */
12
+ import fs from "node:fs/promises";
13
+ import path from "node:path";
14
+ import os from "node:os";
15
+ import { dbPath, openVecStore, projectSlug } from "../rag/vec-store.js";
16
+
17
+ const DEFAULT_ROOTS = [
18
+ path.join(os.homedir(), "ws_npm-packages"),
19
+ path.join(os.homedir(), "ws_firebase"),
20
+ path.join(os.homedir(), "projects"),
21
+ path.join(os.homedir(), "code"),
22
+ ];
23
+
24
+ async function listDirs(root) {
25
+ try {
26
+ const entries = await fs.readdir(root, { withFileTypes: true });
27
+ return entries.filter((e) => e.isDirectory()).map((e) => path.join(root, e.name));
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ async function buildSlugIndex(roots) {
34
+ const index = new Map();
35
+ for (const root of roots) {
36
+ for (const dir of await listDirs(root)) {
37
+ const slug = projectSlug(dir);
38
+ if (slug && !index.has(slug)) index.set(slug, dir);
39
+ }
40
+ }
41
+ return index;
42
+ }
43
+
44
+ function purgeCommand(slug) {
45
+ const slugSafe = slug.replace(/'/g, "''");
46
+ return `sqlite3 ${dbPath()} "DELETE FROM chunks WHERE project_slug='${slugSafe}'; DELETE FROM vec_store_meta WHERE project_slug='${slugSafe}';"`;
47
+ }
48
+
49
+ /**
50
+ * Collect RAG vector-store orphans.
51
+ * @param {object} [opts]
52
+ * @param {string[]} [opts.projectRoots] — directories to look for live
53
+ * projects whose basename matches each slug. Defaults to the usual
54
+ * `~/ws_*`, `~/projects`, `~/code` set.
55
+ * @returns {Promise<{rows: Array, orphans: Array, errors: Array}>}
56
+ */
57
+ export async function collectVectorStoreOrphans({ projectRoots = DEFAULT_ROOTS } = {}) {
58
+ const rows = [];
59
+ const errors = [];
60
+ let db;
61
+ try { db = openVecStore(); } catch (e) {
62
+ return { rows, orphans: [], errors: [{ stage: "open", error: String(e?.message ?? e) }] };
63
+ }
64
+ try {
65
+ const stats = db.prepare(`
66
+ SELECT COALESCE(c.project_slug, '<null>') AS slug,
67
+ COUNT(c.id) AS chunkCount,
68
+ COALESCE(m.last_indexed_commit, '') AS lastCommit,
69
+ COALESCE(m.last_indexed_at, '') AS lastAt
70
+ FROM chunks c
71
+ LEFT JOIN vec_store_meta m ON m.project_slug = c.project_slug
72
+ GROUP BY c.project_slug
73
+ ORDER BY chunkCount DESC
74
+ `).all();
75
+ const slugIndex = await buildSlugIndex(projectRoots);
76
+ for (const r of stats) {
77
+ const resolved = slugIndex.get(r.slug) || null;
78
+ rows.push({ ...r, resolvedDir: resolved, orphan: !resolved && r.slug !== "<null>" });
79
+ }
80
+ } catch (e) {
81
+ errors.push({ stage: "query", error: String(e?.message ?? e) });
82
+ } finally {
83
+ try { db.close(); } catch { /* close errors are non-fatal */ }
84
+ }
85
+ const orphans = rows
86
+ .filter((r) => r.orphan)
87
+ .map((r) => ({ ...r, command: purgeCommand(r.slug) }));
88
+ return { rows, orphans, errors };
89
+ }