@klhapp/skillmux 1.11.2 → 1.13.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 (104) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +20 -10
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +15 -5
  6. package/docs/concepts.md +4 -3
  7. package/docs/configuration.md +22 -5
  8. package/docs/deployment.md +3 -3
  9. package/docs/getting-started.md +32 -7
  10. package/docs/releasing.md +17 -2
  11. package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
  12. package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
  13. package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
  14. package/docs/sdd/cli-remote-target-parity/think.md +183 -0
  15. package/docs/sdd/cli-surface-consistency/think.md +149 -0
  16. package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
  17. package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
  18. package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
  19. package/docs/sdd/feat-packaging-deployment/review.md +95 -0
  20. package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
  21. package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
  22. package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
  23. package/docs/sdd/npm-native-binaries/review.md +103 -0
  24. package/docs/sdd/npm-native-binaries/spec.md +148 -0
  25. package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
  26. package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
  27. package/docs/sdd/runtime-resource-hardening/review.md +86 -0
  28. package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
  29. package/docs/sdd/security-hardening-phase/think.md +129 -0
  30. package/docs/sdd/security-observability/review.md +96 -0
  31. package/docs/sdd/security-observability/spec.md +55 -0
  32. package/docs/sdd/skill-provenance-update/review.md +152 -0
  33. package/docs/sdd/skill-provenance-update/schema.json +193 -0
  34. package/docs/sdd/skill-provenance-update/spec.md +134 -0
  35. package/docs/sdd/supply-chain-hardening/review.md +50 -0
  36. package/docs/sdd/supply-chain-hardening/spec.md +42 -0
  37. package/docs/sdd/target-marker-rehome/spec.md +33 -0
  38. package/docs/skill-management.md +1 -1
  39. package/docs/troubleshooting.md +18 -2
  40. package/package.json +15 -8
  41. package/src/adapters.ts +0 -438
  42. package/src/audit.ts +0 -21
  43. package/src/cli.ts +0 -928
  44. package/src/clients.ts +0 -368
  45. package/src/commands/audit.ts +0 -85
  46. package/src/commands/config.ts +0 -224
  47. package/src/commands/context.ts +0 -104
  48. package/src/commands/core.ts +0 -56
  49. package/src/commands/doctor.ts +0 -97
  50. package/src/commands/eval.ts +0 -88
  51. package/src/commands/init.ts +0 -672
  52. package/src/commands/install.ts +0 -155
  53. package/src/commands/local-vault.ts +0 -60
  54. package/src/commands/models.ts +0 -10
  55. package/src/commands/outdated.ts +0 -115
  56. package/src/commands/project.ts +0 -580
  57. package/src/commands/report.ts +0 -66
  58. package/src/commands/scan.ts +0 -71
  59. package/src/commands/shared.ts +0 -38
  60. package/src/commands/skill.ts +0 -33
  61. package/src/commands/sync.ts +0 -233
  62. package/src/commands/target.ts +0 -225
  63. package/src/commands/update.ts +0 -277
  64. package/src/completions.ts +0 -196
  65. package/src/concurrency-limiter.ts +0 -61
  66. package/src/config-service.ts +0 -398
  67. package/src/config-watcher.ts +0 -253
  68. package/src/config.ts +0 -583
  69. package/src/context.ts +0 -188
  70. package/src/db-audit.ts +0 -286
  71. package/src/db-index.ts +0 -238
  72. package/src/db.ts +0 -3
  73. package/src/deployment.ts +0 -39
  74. package/src/doctor.ts +0 -205
  75. package/src/eval.ts +0 -302
  76. package/src/global-flags.ts +0 -46
  77. package/src/init-agents.ts +0 -329
  78. package/src/init-instructions.ts +0 -192
  79. package/src/init.ts +0 -389
  80. package/src/install.ts +0 -232
  81. package/src/lifecycle.ts +0 -51
  82. package/src/logger.ts +0 -26
  83. package/src/manifest.ts +0 -352
  84. package/src/mcp-registration.ts +0 -89
  85. package/src/metrics.ts +0 -121
  86. package/src/models.ts +0 -20
  87. package/src/output.ts +0 -214
  88. package/src/project-setup.ts +0 -36
  89. package/src/prompts.ts +0 -124
  90. package/src/provenance.ts +0 -99
  91. package/src/rate-limiter.ts +0 -137
  92. package/src/readiness.ts +0 -30
  93. package/src/redact.ts +0 -52
  94. package/src/router-core.ts +0 -666
  95. package/src/rrf.ts +0 -31
  96. package/src/scan.ts +0 -300
  97. package/src/server.ts +0 -930
  98. package/src/setup.ts +0 -145
  99. package/src/snapshot.ts +0 -135
  100. package/src/stats.ts +0 -340
  101. package/src/sync.ts +0 -482
  102. package/src/toml-writer.ts +0 -51
  103. package/src/types.ts +0 -212
  104. package/src/vault.ts +0 -207
package/src/context.ts DELETED
@@ -1,188 +0,0 @@
1
- import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { expandHome } from "./config";
4
-
5
- export interface ContextRecord {
6
- server: string;
7
- token_env?: string;
8
- }
9
-
10
- export interface ContextConfig {
11
- default_context: string;
12
- contexts: Record<string, ContextRecord>;
13
- }
14
-
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 =
21
- | { type: "local"; name: "local" }
22
- | { type: "remote"; name: string; server: string; token_env?: string };
23
-
24
- export const DEFAULT_CONTEXTS_PATH = "~/.config/skillmux/contexts.toml";
25
-
26
- export async function loadContextConfig(filePath?: string): Promise<ContextConfig> {
27
- const targetPath = expandHome(filePath ?? DEFAULT_CONTEXTS_PATH);
28
- const base: ContextConfig = {
29
- default_context: "local",
30
- contexts: { local: { server: "local" } },
31
- };
32
-
33
- if (!existsSync(targetPath)) {
34
- return base;
35
- }
36
-
37
- try {
38
- const text = await Bun.file(targetPath).text();
39
- const parsed = Bun.TOML.parse(text) as Partial<ContextConfig>;
40
- const default_context = typeof parsed.default_context === "string" ? parsed.default_context : "local";
41
- const contexts: Record<string, ContextRecord> = { local: { server: "local" } };
42
-
43
- if (parsed.contexts && typeof parsed.contexts === "object") {
44
- for (const [name, rec] of Object.entries(parsed.contexts)) {
45
- if (name === "local") continue;
46
- if (rec && typeof rec === "object" && typeof (rec as ContextRecord).server === "string") {
47
- contexts[name] = {
48
- server: (rec as ContextRecord).server,
49
- ...(typeof (rec as ContextRecord).token_env === "string" ? { token_env: (rec as ContextRecord).token_env } : {}),
50
- };
51
- }
52
- }
53
- }
54
-
55
- return { default_context, contexts };
56
- } catch {
57
- return base;
58
- }
59
- }
60
-
61
- export async function saveContextConfig(config: ContextConfig, filePath?: string): Promise<void> {
62
- const targetPath = expandHome(filePath ?? DEFAULT_CONTEXTS_PATH);
63
- const dir = dirname(targetPath);
64
- mkdirSync(dir, { recursive: true });
65
-
66
- // Serialize to TOML
67
- let tomlStr = `default_context = "${config.default_context}"\n\n[contexts]\n`;
68
- for (const [name, rec] of Object.entries(config.contexts)) {
69
- if (name === "local") {
70
- tomlStr += `[contexts.local]\nserver = "local"\n\n`;
71
- continue;
72
- }
73
- tomlStr += `[contexts.${name}]\nserver = "${rec.server}"\n`;
74
- if (rec.token_env) {
75
- tomlStr += `token_env = "${rec.token_env}"\n`;
76
- }
77
- tomlStr += `\n`;
78
- }
79
-
80
- const tmpPath = join(dir, `.contexts-${Math.random().toString(36).slice(2)}.tmp`);
81
- writeFileSync(tmpPath, tomlStr, "utf-8");
82
- renameSync(tmpPath, targetPath);
83
- }
84
-
85
- export async function listContexts(filePath?: string): Promise<Array<{ name: string; server: string; token_env?: string; isDefault: boolean }>> {
86
- const config = await loadContextConfig(filePath);
87
- return Object.entries(config.contexts).map(([name, rec]) => ({
88
- name,
89
- server: rec.server,
90
- token_env: rec.token_env,
91
- isDefault: name === config.default_context,
92
- }));
93
- }
94
-
95
- export async function getCurrentContext(filePath?: string): Promise<{ name: string; server: string; token_env?: string }> {
96
- const config = await loadContextConfig(filePath);
97
- const name = config.contexts[config.default_context] ? config.default_context : "local";
98
- const rec = config.contexts[name] ?? { server: "local" };
99
- return { name, server: rec.server, token_env: rec.token_env };
100
- }
101
-
102
- export async function addContext(name: string, record: ContextRecord, filePath?: string): Promise<void> {
103
- if (name === "local") {
104
- throw new Error('Context "local" is reserved and cannot be added or modified');
105
- }
106
- const config = await loadContextConfig(filePath);
107
- config.contexts[name] = record;
108
- await saveContextConfig(config, filePath);
109
- }
110
-
111
- export async function removeContext(name: string, filePath?: string): Promise<void> {
112
- if (name === "local") {
113
- throw new Error('Context "local" is reserved and cannot be removed');
114
- }
115
- const config = await loadContextConfig(filePath);
116
- if (!config.contexts[name]) {
117
- throw new Error(`Context "${name}" does not exist`);
118
- }
119
- delete config.contexts[name];
120
- if (config.default_context === name) {
121
- config.default_context = "local";
122
- }
123
- await saveContextConfig(config, filePath);
124
- }
125
-
126
- export async function useContext(name: string, filePath?: string): Promise<void> {
127
- const config = await loadContextConfig(filePath);
128
- if (!config.contexts[name]) {
129
- throw new Error(`Context "${name}" does not exist`);
130
- }
131
- config.default_context = name;
132
- await saveContextConfig(config, filePath);
133
- }
134
-
135
- export async function resolveContext(
136
- flags: { context?: string; server?: string },
137
- filePath?: string
138
- ): Promise<ResolvedContext> {
139
- // Precedence 1: Explicit flags
140
- if (flags.context && flags.server) {
141
- throw new Error("Cannot specify both --context and --server");
142
- }
143
- if (flags.context) {
144
- if (flags.context === "local") return { type: "local", name: "local" };
145
- const config = await loadContextConfig(filePath);
146
- const rec = config.contexts[flags.context];
147
- if (!rec) {
148
- throw new Error(`Specified context "${flags.context}" does not exist`);
149
- }
150
- if (rec.server === "local") return { type: "local", name: "local" };
151
- return { type: "remote", name: flags.context, server: rec.server, token_env: rec.token_env };
152
- }
153
- if (flags.server) {
154
- if (flags.server === "local") return { type: "local", name: "local" };
155
- return { type: "remote", name: "custom", server: flags.server };
156
- }
157
-
158
- // Precedence 2: Environment variables
159
- const envContext = process.env.SKILLMUX_CONTEXT;
160
- const envServer = process.env.SKILLMUX_SERVER;
161
- if (envContext && envServer) {
162
- throw new Error("Cannot specify both SKILLMUX_CONTEXT and SKILLMUX_SERVER");
163
- }
164
- if (envContext) {
165
- if (envContext === "local") return { type: "local", name: "local" };
166
- const config = await loadContextConfig(filePath);
167
- const rec = config.contexts[envContext];
168
- if (!rec) {
169
- throw new Error(`Configured SKILLMUX_CONTEXT "${envContext}" does not exist`);
170
- }
171
- if (rec.server === "local") return { type: "local", name: "local" };
172
- return { type: "remote", name: envContext, server: rec.server, token_env: rec.token_env };
173
- }
174
- if (envServer) {
175
- if (envServer === "local") return { type: "local", name: "local" };
176
- return { type: "remote", name: "custom", server: envServer };
177
- }
178
-
179
- // Precedence 3 & 4: Configured default context -> local
180
- const config = await loadContextConfig(filePath);
181
- const defName = config.contexts[config.default_context] ? config.default_context : "local";
182
- const defRec = config.contexts[defName] ?? { server: "local" };
183
-
184
- if (defName === "local" || defRec.server === "local") {
185
- return { type: "local", name: "local" };
186
- }
187
- return { type: "remote", name: defName, server: defRec.server, token_env: defRec.token_env };
188
- }
package/src/db-audit.ts DELETED
@@ -1,286 +0,0 @@
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
- }