@klhapp/skillmux 1.9.3 → 1.11.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 (53) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +19 -19
  3. package/docs/README.md +4 -4
  4. package/docs/assets/architecture-dark.svg +39 -32
  5. package/docs/assets/architecture-light.svg +25 -18
  6. package/docs/cli.md +147 -36
  7. package/docs/concepts.md +11 -11
  8. package/docs/configuration.md +7 -5
  9. package/docs/deployment.md +10 -6
  10. package/docs/getting-started.md +18 -14
  11. package/docs/mcp-routing.md +1 -1
  12. package/docs/skill-management.md +17 -11
  13. package/docs/troubleshooting.md +4 -4
  14. package/package.json +1 -1
  15. package/src/adapters.ts +157 -11
  16. package/src/cli.ts +396 -1319
  17. package/src/commands/audit.ts +53 -56
  18. package/src/commands/config.ts +33 -26
  19. package/src/commands/context.ts +104 -0
  20. package/src/commands/core.ts +7 -3
  21. package/src/commands/doctor.ts +97 -0
  22. package/src/commands/eval.ts +22 -15
  23. package/src/commands/init.ts +672 -0
  24. package/src/commands/install.ts +132 -0
  25. package/src/commands/local-vault.ts +60 -0
  26. package/src/commands/models.ts +10 -0
  27. package/src/commands/outdated.ts +2 -1
  28. package/src/commands/project.ts +194 -51
  29. package/src/commands/report.ts +66 -0
  30. package/src/commands/scan.ts +61 -0
  31. package/src/commands/shared.ts +7 -14
  32. package/src/commands/skill.ts +33 -0
  33. package/src/commands/sync.ts +232 -0
  34. package/src/commands/target.ts +45 -15
  35. package/src/commands/update.ts +2 -1
  36. package/src/completions.ts +41 -15
  37. package/src/config-service.ts +4 -54
  38. package/src/context.ts +8 -3
  39. package/src/db-audit.ts +286 -0
  40. package/src/db-index.ts +238 -0
  41. package/src/db.ts +3 -521
  42. package/src/global-flags.ts +46 -0
  43. package/src/init-agents.ts +329 -0
  44. package/src/init-instructions.ts +47 -28
  45. package/src/logger.ts +26 -0
  46. package/src/mcp-registration.ts +89 -0
  47. package/src/output.ts +80 -18
  48. package/src/prompts.ts +75 -20
  49. package/src/router-core.ts +8 -27
  50. package/src/scan.ts +19 -19
  51. package/src/server.ts +161 -14
  52. package/src/toml-writer.ts +51 -0
  53. package/src/init-clients.ts +0 -220
@@ -1,3 +1,5 @@
1
+ import { MANAGED_PINS_AGENT_IDS, SUPPORTED_AGENT_IDS } from "./init-agents";
2
+
1
3
  export type ShellType = "bash" | "zsh" | "fish";
2
4
 
3
5
  const TOP_LEVEL_COMMANDS: { name: string; description: string }[] = [
@@ -6,7 +8,7 @@ const TOP_LEVEL_COMMANDS: { name: string; description: string }[] = [
6
8
  { name: "serve", description: "Start MCP server" },
7
9
  { name: "index", description: "Rebuild local search index" },
8
10
  { name: "sync", description: "Synchronize vault skills" },
9
- { name: "init", description: "Configure this machine and its clients" },
11
+ { name: "init", description: "Configure this machine and its agents" },
10
12
  { name: "project", description: "Configure project-scoped skills" },
11
13
  { name: "target", description: "Manage advanced skill-delivery targets" },
12
14
  { name: "core", description: "Pin/unpin skills into [core]" },
@@ -62,18 +64,25 @@ _skillmux_completions() {
62
64
  local-vault)
63
65
  COMPREPLY=( $(compgen -W "init" -- "$cur") )
64
66
  ;;
65
- --client)
66
- COMPREPLY=( $(compgen -W "claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp" -- "$cur") )
67
+ eval)
68
+ COMPREPLY=( $(compgen -W "promote" -- "$cur") )
67
69
  ;;
68
- --target)
69
- COMPREPLY=( $(compgen -W "agent-skills claude-code codex custom" -- "$cur") )
70
+ --agent)
71
+ if [ "\${COMP_WORDS[1]}" = "project" ]; then
72
+ COMPREPLY=( $(compgen -W "${MANAGED_PINS_AGENT_IDS.join(" ")}" -- "$cur") )
73
+ else
74
+ COMPREPLY=( $(compgen -W "${SUPPORTED_AGENT_IDS.join(" ")}" -- "$cur") )
75
+ fi
70
76
  ;;
71
77
  esac
72
78
  if [ "\${COMP_WORDS[1]}" = "init" ] && [ "\${#COMPREPLY[@]}" -eq 0 ]; then
73
- COMPREPLY=( $(compgen -W "--client --target --dir --vault --core --migrate-full-vault --no-instructions --no-sync --interactive --yes --dry-run --json" -- "$cur") )
79
+ COMPREPLY=( $(compgen -W "--agent --vault --core --migrate-full-vault --show-mcp-setup --register-mcp --no-instructions --no-sync --interactive --yes --dry-run --json" -- "$cur") )
74
80
  fi
75
81
  if [ "\${COMP_WORDS[1]}" = "project" ] && [ "\${COMP_WORDS[2]}" = "init" ]; then
76
- COMPREPLY=( $(compgen -W "--name --skill --client --target --no-sync --interactive --yes --dry-run --json" -- "$cur") )
82
+ COMPREPLY=( $(compgen -W "--name --skill --agent --target --register-mcp --no-sync --interactive --yes --dry-run --json" -- "$cur") )
83
+ fi
84
+ if [ "\${COMP_WORDS[1]}" = "eval" ] && [ "\${COMP_WORDS[2]}" = "promote" ]; then
85
+ COMPREPLY=( $(compgen -W "--since --out --dry-run --yes --json" -- "$cur") )
77
86
  fi
78
87
  }
79
88
  complete -F _skillmux_completions skillmux
@@ -92,12 +101,12 @@ ${commands}
92
101
  _describe -t commands 'skillmux command' commands
93
102
  elif [[ "$words[2]" == "init" ]]; then
94
103
  _arguments \\
95
- '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp)' \\
96
- '*--target[select a delivery target]:target:(agent-skills claude-code codex custom)' \\
97
- '--dir[custom target directory]:directory:_directories' \\
104
+ '*--agent[select an agent]:agent:(${SUPPORTED_AGENT_IDS.join(" ")})' \\
98
105
  '--vault[vault directory]:directory:_directories' \\
99
106
  '*--core[seed a core skill]:skill id:' \\
100
107
  '--migrate-full-vault[convert a full-vault symlink to managed pins]' \\
108
+ '--show-mcp-setup[also print the MCP registration snippet]' \\
109
+ '--register-mcp[register skillmux via the agent own CLI]' \\
101
110
  '--no-instructions[skip managed instruction files]' \\
102
111
  '--no-sync[save setup without synchronizing targets]' \\
103
112
  '--interactive[force guided setup]' \\
@@ -109,8 +118,9 @@ ${commands}
109
118
  '1:project directory:_directories' \\
110
119
  '--name[project group name]:group:' \\
111
120
  '*--skill[project skill]:skill id:' \\
112
- '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity)' \\
121
+ '*--agent[select an agent]:agent:(${MANAGED_PINS_AGENT_IDS.join(" ")})' \\
113
122
  '*--target[select an advanced target]:target:' \\
123
+ '--register-mcp[register a project-scoped MCP server for claude-code]' \\
114
124
  '--no-sync[save setup without synchronizing targets]' \\
115
125
  '--interactive[force guided setup]' \\
116
126
  '--yes[apply without prompts]' \\
@@ -118,6 +128,15 @@ ${commands}
118
128
  '--json[emit a JSON envelope]'
119
129
  elif [[ "$words[2]" == "project" && CURRENT == 3 ]]; then
120
130
  _values 'project command' init list show add-path remove-path pin unpin attach detach
131
+ elif [[ "$words[2]" == "eval" && "$words[3]" == "promote" ]]; then
132
+ _arguments \
133
+ '--since[time window]:window:' \
134
+ '--out[output file]:file:_files' \
135
+ '--dry-run[print the plan without writing]' \
136
+ '--yes[apply without prompts]' \
137
+ '--json[emit a JSON envelope]'
138
+ elif [[ "$words[2]" == "eval" && CURRENT == 3 ]]; then
139
+ _values 'eval command' promote
121
140
  elif [[ "$words[2]" == "target" && CURRENT == 3 ]]; then
122
141
  _values 'target command' list show add remove
123
142
  elif [[ "$words[2]" == "skill" && CURRENT == 3 ]]; then
@@ -139,12 +158,12 @@ _skillmux "$@"
139
158
  return `# fish completion for skillmux
140
159
  complete -c skillmux -f
141
160
  ${topLevel}
142
- complete -c skillmux -n "__fish_seen_subcommand_from init" -l client -x -a "claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp" -d "Select a client"
143
- complete -c skillmux -n "__fish_seen_subcommand_from init" -l target -x -a "agent-skills claude-code codex custom" -d "Select a delivery target"
144
- complete -c skillmux -n "__fish_seen_subcommand_from init" -l dir -r -d "Custom target directory"
161
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l agent -x -a "${SUPPORTED_AGENT_IDS.join(" ")}" -d "Select an agent"
145
162
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l vault -r -d "Vault directory"
146
163
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l core -x -d "Seed a core skill"
147
164
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l migrate-full-vault -d "Convert a full-vault symlink"
165
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l show-mcp-setup -d "Also print the MCP registration snippet"
166
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l register-mcp -d "Register skillmux via the agent's own CLI"
148
167
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-instructions -d "Skip managed instruction files"
149
168
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-sync -d "Save without synchronizing"
150
169
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l interactive -d "Force guided setup"
@@ -154,11 +173,18 @@ complete -c skillmux -n "__fish_seen_subcommand_from init" -l json -d "Emit a JS
154
173
  complete -c skillmux -n "__fish_seen_subcommand_from project" -a "init list show add-path remove-path pin unpin attach detach" -d "Manage projects"
155
174
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l name -x -d "Project group name"
156
175
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l skill -x -d "Project skill"
157
- complete -c skillmux -n "__fish_seen_subcommand_from project" -l client -x -a "claude-code codex gemini-cli opencode github-copilot windsurf antigravity" -d "Select a client"
176
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l agent -x -a "${MANAGED_PINS_AGENT_IDS.join(" ")}" -d "Select an agent"
158
177
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l target -x -d "Select an advanced delivery target"
178
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l register-mcp -d "Register a project-scoped MCP server for claude-code"
159
179
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l no-sync -d "Save without synchronizing"
160
180
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l interactive -d "Force guided setup"
161
181
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l yes -d "Apply without prompts"
182
+ complete -c skillmux -n "__fish_seen_subcommand_from eval" -a "promote" -d "Promote correlated fetches into eval cases"
183
+ complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l since -x -d "Time window"
184
+ complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l out -r -d "Output file"
185
+ complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l dry-run -d "Print the plan without writing"
186
+ complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l yes -d "Apply without prompts"
187
+ complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l json -d "Emit a JSON envelope"
162
188
  complete -c skillmux -n "__fish_seen_subcommand_from target" -a "list show add remove" -d "Manage targets"
163
189
  complete -c skillmux -n "__fish_seen_subcommand_from core" -a "pin unpin" -d "Manage [core] pins"
164
190
  complete -c skillmux -n "__fish_seen_subcommand_from skill" -a "which" -d "Show which root resolves a skill_id"
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { DEFAULT_CONFIG_PATH, expandHome, loadConfig } from "./config";
5
5
  import { describeDeployment } from "./deployment";
6
6
  import type { Config } from "./types";
7
+ import { stringifyToml } from "./toml-writer";
7
8
 
8
9
  export type ConfigSource = "default" | "toml" | "environment";
9
10
  export type ConfigSourceMap = Record<string, ConfigSource>;
@@ -308,7 +309,7 @@ export async function getDottedKey(key: string, configPath?: string): Promise<un
308
309
  export async function setDottedKey(
309
310
  key: string,
310
311
  rawValStr: string,
311
- opts?: { configPath?: string; dryRun?: boolean; targetName?: string }
312
+ opts?: { configPath?: string; dryRun?: boolean; contextName?: string }
312
313
  ): Promise<SetConfigResult> {
313
314
  validateDottedKey(key);
314
315
  if (isEnvMasked(key)) {
@@ -316,7 +317,7 @@ export async function setDottedKey(
316
317
  }
317
318
 
318
319
  const path = opts?.configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH;
319
- const targetName = opts?.targetName ?? "local";
320
+ const contextName = opts?.contextName ?? "local";
320
321
 
321
322
  const { effective: priorEffective, rawToml } = await getEffectiveConfig(path);
322
323
  const priorVal = getNestedValue(priorEffective as Record<string, any>, key);
@@ -363,7 +364,7 @@ export async function setDottedKey(
363
364
  key,
364
365
  prior_val: priorVal,
365
366
  resulting_val: parsedVal,
366
- target: targetName,
367
+ target: contextName,
367
368
  prior_revision: priorRevision,
368
369
  resulting_revision: resultingRevision,
369
370
  persistence,
@@ -373,57 +374,6 @@ export async function setDottedKey(
373
374
  };
374
375
  }
375
376
 
376
- export function stringifyToml(obj: Record<string, any>): string {
377
- let out = "";
378
- const topLevel: Record<string, any> = {};
379
- const sections: Record<string, any> = {};
380
-
381
- for (const [k, v] of Object.entries(obj)) {
382
- if (typeof v === "object" && v !== null && !Array.isArray(v)) {
383
- sections[k] = v;
384
- } else {
385
- topLevel[k] = v;
386
- }
387
- }
388
-
389
- for (const [k, v] of Object.entries(topLevel)) {
390
- out += `${k} = ${formatTomlVal(v)}\n`;
391
- }
392
- if (Object.keys(topLevel).length > 0) out += "\n";
393
-
394
- for (const [secName, secObj] of Object.entries(sections)) {
395
- out += stringifyTomlSection([secName], secObj);
396
- }
397
-
398
- return out;
399
- }
400
-
401
- function stringifyTomlSection(path: string[], obj: Record<string, any>): string {
402
- let out = `[${path.join(".")}]\n`;
403
- const subSections: Record<string, any> = {};
404
-
405
- for (const [k, v] of Object.entries(obj)) {
406
- if (typeof v === "object" && v !== null && !Array.isArray(v)) {
407
- subSections[k] = v;
408
- } else {
409
- out += `${k} = ${formatTomlVal(v)}\n`;
410
- }
411
- }
412
- out += "\n";
413
-
414
- for (const [subName, subObj] of Object.entries(subSections)) {
415
- out += stringifyTomlSection([...path, subName], subObj);
416
- }
417
-
418
- return out;
419
- }
420
-
421
- function formatTomlVal(v: unknown): string {
422
- if (typeof v === "string") return JSON.stringify(v);
423
- if (typeof v === "boolean" || typeof v === "number") return String(v);
424
- if (Array.isArray(v)) return JSON.stringify(v);
425
- return JSON.stringify(v);
426
- }
427
377
 
428
378
  export async function getLocalConfigStatus(configPath?: string): Promise<ConfigStatusResponse> {
429
379
  const { effective } = await getEffectiveConfig(configPath);
package/src/context.ts CHANGED
@@ -12,7 +12,12 @@ export interface ContextConfig {
12
12
  contexts: Record<string, ContextRecord>;
13
13
  }
14
14
 
15
- export type ResolvedTarget =
15
+ /**
16
+ * Context resolution: `local` = this CLI process has the Skillmux runtime (vault,
17
+ * index, audit db, embeddings/reranker clients) loaded in-process; `remote` = this
18
+ * CLI process is a thin network client to a separate process elsewhere that owns that runtime.
19
+ */
20
+ export type ResolvedContext =
16
21
  | { type: "local"; name: "local" }
17
22
  | { type: "remote"; name: string; server: string; token_env?: string };
18
23
 
@@ -127,10 +132,10 @@ export async function useContext(name: string, filePath?: string): Promise<void>
127
132
  await saveContextConfig(config, filePath);
128
133
  }
129
134
 
130
- export async function resolveTarget(
135
+ export async function resolveContext(
131
136
  flags: { context?: string; server?: string },
132
137
  filePath?: string
133
- ): Promise<ResolvedTarget> {
138
+ ): Promise<ResolvedContext> {
134
139
  // Precedence 1: Explicit flags
135
140
  if (flags.context && flags.server) {
136
141
  throw new Error("Cannot specify both --context and --server");
@@ -0,0 +1,286 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import type { AuditCandidate, AuditRow } from "./types";
6
+
7
+ export function openAudit(stateDir: string): Database {
8
+ mkdirSync(stateDir, { recursive: true });
9
+ const db = new Database(join(stateDir, "audit.sqlite3"), { create: true });
10
+ // auto_vacuum only takes on an empty database, so it must precede both the
11
+ // journal-mode switch and any CREATE TABLE. It is what lets a retention
12
+ // prune reclaim space without a full VACUUM.
13
+ db.run("PRAGMA auto_vacuum = INCREMENTAL");
14
+ db.run("PRAGMA journal_mode = WAL");
15
+ db.run("PRAGMA busy_timeout = 2000");
16
+ db.run(`CREATE TABLE IF NOT EXISTS audit (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ ts TEXT NOT NULL,
19
+ request_id TEXT,
20
+ query TEXT NOT NULL,
21
+ retrieval TEXT NOT NULL DEFAULT 'lexical',
22
+ degraded_from TEXT,
23
+ degradation_reason TEXT,
24
+ candidates TEXT NOT NULL,
25
+ latency_ms INTEGER NOT NULL
26
+ )`);
27
+ // CREATE TABLE IF NOT EXISTS no-ops on a table opened from before request_id
28
+ // existed (AC4), so add it explicitly when missing.
29
+ const auditColumns = new Set(
30
+ (db.query("PRAGMA table_info(audit)").all() as { name: string }[]).map((c) => c.name),
31
+ );
32
+ if (!auditColumns.has("request_id")) {
33
+ db.run("ALTER TABLE audit ADD COLUMN request_id TEXT");
34
+ }
35
+ db.run(`CREATE TABLE IF NOT EXISTS fetch (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ ts TEXT NOT NULL,
38
+ skill_id TEXT NOT NULL,
39
+ request_id TEXT,
40
+ resolve_audit_id INTEGER,
41
+ rank_at_resolve INTEGER
42
+ )`);
43
+ db.run(`CREATE TABLE IF NOT EXISTS admin_audit (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ ts TEXT NOT NULL,
46
+ changes TEXT NOT NULL,
47
+ resulting_revision TEXT NOT NULL,
48
+ row_hash TEXT NOT NULL,
49
+ prev_row_hash TEXT
50
+ )`);
51
+ adoptAuditFromIndex(db, stateDir);
52
+ return db;
53
+ }
54
+
55
+ // Audit rows used to live in index.sqlite3. Move any that remain there into the
56
+ // audit store, then drop the old table so the index carries no user queries.
57
+ function adoptAuditFromIndex(db: Database, stateDir: string): void {
58
+ const indexPath = join(stateDir, "index.sqlite3");
59
+ if (!existsSync(indexPath)) return;
60
+
61
+ db.run("ATTACH DATABASE ? AS legacy", [indexPath]);
62
+ try {
63
+ const legacyAudit = db
64
+ .query("SELECT name FROM legacy.sqlite_master WHERE type = 'table' AND name = 'audit'")
65
+ .get();
66
+ if (!legacyAudit) return;
67
+
68
+ // Older audit tables predate the retrieval columns and carry outcome /
69
+ // degraded / selected_skill_id instead. Select what is actually there and
70
+ // let the canonical defaults stand in for the rest.
71
+ const legacyColumns = new Set(
72
+ (db.query("PRAGMA legacy.table_info(audit)").all() as { name: string }[]).map((c) => c.name),
73
+ );
74
+ const retrieval = legacyColumns.has("retrieval") ? "COALESCE(retrieval, 'lexical')" : "'lexical'";
75
+ const degradedFrom = legacyColumns.has("degraded_from") ? "degraded_from" : "NULL";
76
+ const degradationReason = legacyColumns.has("degradation_reason") ? "degradation_reason" : "NULL";
77
+
78
+ // SQLite commits atomically across attached databases, so the copy and the
79
+ // drop either both land or neither does.
80
+ db.transaction(() => {
81
+ db.run(`INSERT INTO audit (ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
82
+ SELECT ts, query, ${retrieval}, ${degradedFrom}, ${degradationReason}, candidates, latency_ms FROM legacy.audit`);
83
+ db.run("DROP TABLE legacy.audit");
84
+ })();
85
+ } finally {
86
+ db.run("DETACH DATABASE legacy");
87
+ }
88
+ }
89
+
90
+ export interface AuditInsert {
91
+ ts: string;
92
+ request_id?: string | null;
93
+ query: string;
94
+ retrieval: AuditRow["retrieval"];
95
+ degraded_from?: string | null;
96
+ degradation_reason?: string | null;
97
+ candidates: AuditCandidate[];
98
+ latency_ms: number;
99
+ }
100
+
101
+ export function insertAudit(db: Database, row: AuditInsert): void {
102
+ db.run(
103
+ `INSERT INTO audit (ts, request_id, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
104
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
105
+ [
106
+ row.ts,
107
+ row.request_id ?? null,
108
+ row.query,
109
+ row.retrieval,
110
+ row.degraded_from ?? null,
111
+ row.degradation_reason ?? null,
112
+ JSON.stringify(row.candidates),
113
+ row.latency_ms,
114
+ ],
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Correlation lookup for AC5/AC7: looks up the resolve that produced
120
+ * `requestId`, or null when it names no known resolve (including malformed
121
+ * input, which is never validated at the boundary per AC7).
122
+ */
123
+ export function getAuditRowByRequestId(
124
+ db: Database,
125
+ requestId: string,
126
+ ): { id: number; candidates: AuditCandidate[] } | null {
127
+ const row = db
128
+ .query("SELECT id, candidates FROM audit WHERE request_id = ?")
129
+ .get(requestId) as { id: number; candidates: string } | null;
130
+ if (!row) return null;
131
+ return { id: row.id, candidates: JSON.parse(row.candidates) as AuditCandidate[] };
132
+ }
133
+
134
+ export interface FetchInsert {
135
+ ts: string;
136
+ skill_id: string;
137
+ request_id?: string | null;
138
+ resolve_audit_id?: number | null;
139
+ rank_at_resolve?: number | null;
140
+ }
141
+
142
+ export function insertFetch(db: Database, row: FetchInsert): void {
143
+ db.run(
144
+ `INSERT INTO fetch (ts, skill_id, request_id, resolve_audit_id, rank_at_resolve)
145
+ VALUES (?, ?, ?, ?, ?)`,
146
+ [
147
+ row.ts,
148
+ row.skill_id,
149
+ row.request_id ?? null,
150
+ row.resolve_audit_id ?? null,
151
+ row.rank_at_resolve ?? null,
152
+ ],
153
+ );
154
+ }
155
+
156
+ export interface PruneResult {
157
+ audit_deleted: number;
158
+ fetch_deleted: number;
159
+ admin_audit_deleted: number;
160
+ }
161
+
162
+ /**
163
+ * Deletes resolve, fetch, and admin_audit rows with ts before `cutoffIso`,
164
+ * each by its own timestamp; no FK ties them, so a fetch outliving its
165
+ * resolve row simply reads back uncorrelated (AC7's existing null path).
166
+ * admin_audit shares this cutoff rather than a separate retention config
167
+ * (AC10) — its hash chain is unaffected since pruning only ever removes the
168
+ * oldest rows, never rows in the middle of the chain. Reclaims the freed
169
+ * pages with an incremental vacuum, which only touches audit.sqlite3 (AC16).
170
+ */
171
+ export function pruneAuditBefore(db: Database, cutoffIso: string): PruneResult {
172
+ const auditResult = db.run("DELETE FROM audit WHERE ts < ?", [cutoffIso]);
173
+ const fetchResult = db.run("DELETE FROM fetch WHERE ts < ?", [cutoffIso]);
174
+ const adminAuditResult = db.run("DELETE FROM admin_audit WHERE ts < ?", [cutoffIso]);
175
+ db.run("PRAGMA incremental_vacuum");
176
+
177
+ return {
178
+ audit_deleted: auditResult.changes,
179
+ fetch_deleted: fetchResult.changes,
180
+ admin_audit_deleted: adminAuditResult.changes,
181
+ };
182
+ }
183
+
184
+ /** AC12: retentionDays <= 0 disables pruning entirely. */
185
+ export function pruneAudit(db: Database, retentionDays: number, now: Date = new Date()): PruneResult {
186
+ if (retentionDays <= 0) return { audit_deleted: 0, fetch_deleted: 0, admin_audit_deleted: 0 };
187
+ const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
188
+ return pruneAuditBefore(db, cutoff);
189
+ }
190
+
191
+ export interface AdminAuditChange {
192
+ key: string;
193
+ old_value: unknown;
194
+ new_value: unknown;
195
+ }
196
+
197
+ export interface AdminAuditInsert {
198
+ ts: string;
199
+ changes: AdminAuditChange[];
200
+ resulting_revision: string;
201
+ }
202
+
203
+ export interface AdminAuditRow {
204
+ id: number;
205
+ ts: string;
206
+ changes: AdminAuditChange[];
207
+ resulting_revision: string;
208
+ row_hash: string;
209
+ prev_row_hash: string | null;
210
+ }
211
+
212
+ function computeAdminAuditRowHash(
213
+ prevRowHash: string | null,
214
+ fields: { ts: string; changes: AdminAuditChange[]; resulting_revision: string },
215
+ ): string {
216
+ const payload = JSON.stringify({ prev_row_hash: prevRowHash, ...fields });
217
+ return createHash("sha256").update(payload).digest("hex");
218
+ }
219
+
220
+ /**
221
+ * Appends one tamper-evident admin_audit row, chaining its hash to the
222
+ * previous row's hash (or null for the first row) so any out-of-band
223
+ * edit/delete breaks the chain — see verifyAdminAuditChain.
224
+ */
225
+ export function insertAdminAuditRow(db: Database, row: AdminAuditInsert): AdminAuditRow {
226
+ const prevRow = db
227
+ .query("SELECT row_hash FROM admin_audit ORDER BY id DESC LIMIT 1")
228
+ .get() as { row_hash: string } | null;
229
+ const prevRowHash = prevRow?.row_hash ?? null;
230
+ const rowHash = computeAdminAuditRowHash(prevRowHash, row);
231
+
232
+ db.run(
233
+ `INSERT INTO admin_audit (ts, changes, resulting_revision, row_hash, prev_row_hash)
234
+ VALUES (?, ?, ?, ?, ?)`,
235
+ [row.ts, JSON.stringify(row.changes), row.resulting_revision, rowHash, prevRowHash],
236
+ );
237
+
238
+ const inserted = db.query("SELECT last_insert_rowid() AS id").get() as { id: number };
239
+ return {
240
+ id: inserted.id,
241
+ ts: row.ts,
242
+ changes: row.changes,
243
+ resulting_revision: row.resulting_revision,
244
+ row_hash: rowHash,
245
+ prev_row_hash: prevRowHash,
246
+ };
247
+ }
248
+
249
+ export interface AdminAuditChainResult {
250
+ valid: boolean;
251
+ broken_at_id: number | null;
252
+ }
253
+
254
+ /** Walks admin_audit in insertion order and reports whether the hash chain is unbroken. */
255
+ export function verifyAdminAuditChain(db: Database): AdminAuditChainResult {
256
+ const rows = db
257
+ .query("SELECT id, ts, changes, resulting_revision, row_hash, prev_row_hash FROM admin_audit ORDER BY id ASC")
258
+ .all() as { id: number; ts: string; changes: string; resulting_revision: string; row_hash: string; prev_row_hash: string | null }[];
259
+
260
+ let expectedPrevHash: string | null = null;
261
+ for (const row of rows) {
262
+ if (row.prev_row_hash !== expectedPrevHash) {
263
+ return { valid: false, broken_at_id: row.id };
264
+ }
265
+ const recomputed = computeAdminAuditRowHash(expectedPrevHash, {
266
+ ts: row.ts,
267
+ changes: JSON.parse(row.changes),
268
+ resulting_revision: row.resulting_revision,
269
+ });
270
+ if (recomputed !== row.row_hash) {
271
+ return { valid: false, broken_at_id: row.id };
272
+ }
273
+ expectedPrevHash = row.row_hash;
274
+ }
275
+ return { valid: true, broken_at_id: null };
276
+ }
277
+
278
+ /** Dry-run counterpart of pruneAuditBefore: counts without deleting (AC15). */
279
+ export function countPrunable(db: Database, cutoffIso: string): PruneResult {
280
+ const auditRow = db.query("SELECT count(*) AS n FROM audit WHERE ts < ?").get(cutoffIso) as { n: number };
281
+ const fetchRow = db.query("SELECT count(*) AS n FROM fetch WHERE ts < ?").get(cutoffIso) as { n: number };
282
+ const adminAuditRow = db
283
+ .query("SELECT count(*) AS n FROM admin_audit WHERE ts < ?")
284
+ .get(cutoffIso) as { n: number };
285
+ return { audit_deleted: auditRow.n, fetch_deleted: fetchRow.n, admin_audit_deleted: adminAuditRow.n };
286
+ }