@devflow-tools/database 0.16.0 → 0.16.3

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/src/database.ts CHANGED
@@ -71,6 +71,33 @@ export interface GovernanceAuditRecord {
71
71
  signature: string;
72
72
  }
73
73
 
74
+ const CONTEXT_REQUIRED_SKILLS = new Set([
75
+ 'react', 'vue', 'nest', 'nextjs', 'graphql', 'typescript',
76
+ ]);
77
+
78
+ function toolNameMatches(event: any, expected: string): boolean {
79
+ if (event.blocked || event.error) return false;
80
+ const name = String(event.mcpToolName ?? event.toolName ?? '')
81
+ .replace(/^mcp__[^_]+__/, '');
82
+ return name === expected;
83
+ }
84
+
85
+ function obligationsForSkill(skillName: string, configured: unknown): string[] {
86
+ const configuredTools = Array.isArray(configured)
87
+ ? configured.filter((tool): tool is string => typeof tool === 'string' && tool.length > 0)
88
+ : [];
89
+ const family = skillName.split(':').pop() ?? skillName;
90
+ if (CONTEXT_REQUIRED_SKILLS.has(family)) {
91
+ return [`${family}:context`, `${family}:domain`];
92
+ }
93
+ if (family === 'context') return ['get_project_context'];
94
+ if (family === 'graph') return ['get_dependency_graph'];
95
+ if (family === 'knowledge') return ['get_knowledge'];
96
+ if (family === 'memory') return ['get_memory'];
97
+ if (family === 'workflow') return ['run_workflow'];
98
+ return configuredTools.slice(0, 1);
99
+ }
100
+
74
101
  export interface HookReceiptRecord {
75
102
  projectRoot: string;
76
103
  lastMcpCall?: number;
@@ -78,6 +105,15 @@ export interface HookReceiptRecord {
78
105
  updatedAt: number;
79
106
  }
80
107
 
108
+ export interface ContextReceiptRecord {
109
+ projectRoot: string;
110
+ sessionId: string;
111
+ executionId: string;
112
+ contextHash: string;
113
+ issuedAt: number;
114
+ expiresAt: number;
115
+ }
116
+
81
117
  export interface MemoryDistillCheckpointRecord {
82
118
  id: string;
83
119
  projectRoot: string;
@@ -225,7 +261,8 @@ export class DevFlowDatabase {
225
261
  error TEXT,
226
262
  blocked INTEGER DEFAULT 0 CHECK (blocked IN (0, 1)),
227
263
  block_reason TEXT,
228
- failure_category TEXT
264
+ failure_category TEXT,
265
+ tool_use_id TEXT
229
266
  );
230
267
 
231
268
  CREATE INDEX IF NOT EXISTS idx_executions_skill ON skill_executions(skill_name);
@@ -248,7 +285,8 @@ export class DevFlowDatabase {
248
285
  mcp_tool_calls INTEGER DEFAULT 0,
249
286
  direct_tool_calls INTEGER DEFAULT 0,
250
287
  subagent_count INTEGER DEFAULT 0,
251
- status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed'))
288
+ status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed')),
289
+ metadata TEXT
252
290
  );
253
291
 
254
292
  CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_root);
@@ -315,6 +353,19 @@ export class DevFlowDatabase {
315
353
  updated_at INTEGER NOT NULL
316
354
  );
317
355
 
356
+ CREATE TABLE IF NOT EXISTS devflow_context_receipts (
357
+ project_root TEXT NOT NULL,
358
+ session_id TEXT NOT NULL,
359
+ execution_id TEXT NOT NULL,
360
+ context_hash TEXT NOT NULL,
361
+ issued_at INTEGER NOT NULL,
362
+ expires_at INTEGER NOT NULL,
363
+ PRIMARY KEY (project_root, session_id, execution_id)
364
+ );
365
+
366
+ CREATE INDEX IF NOT EXISTS idx_context_receipts_expiry
367
+ ON devflow_context_receipts(expires_at);
368
+
318
369
  CREATE TABLE IF NOT EXISTS devflow_memory_distill_checkpoints (
319
370
  id TEXT PRIMARY KEY,
320
371
  project_root TEXT NOT NULL,
@@ -340,6 +391,13 @@ export class DevFlowDatabase {
340
391
  // Migration: workflow run persistence
341
392
  try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN workflow_run_id TEXT'); } catch {}
342
393
  try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN failure_category TEXT'); } catch {}
394
+ try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN tool_use_id TEXT'); } catch {}
395
+ try { this.db.exec('ALTER TABLE sessions ADD COLUMN metadata TEXT'); } catch {}
396
+ try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT'); } catch {}
397
+ try {
398
+ this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_events_session_tool_use
399
+ ON tool_call_events(session_id, tool_use_id) WHERE tool_use_id IS NOT NULL`);
400
+ } catch {}
343
401
  try { this.db.exec('ALTER TABLE devflow_rules ADD COLUMN gate INTEGER NOT NULL DEFAULT 1'); } catch {}
344
402
  try { this.db.exec('ALTER TABLE devflow_rules ADD COLUMN updated_at INTEGER'); } catch {}
345
403
  this.db.exec('UPDATE devflow_rules SET updated_at = created_at WHERE updated_at IS NULL');
@@ -631,6 +689,27 @@ export class DevFlowDatabase {
631
689
  `).run(params.id, params.projectRoot, params.label ?? null, params.startedAt);
632
690
  }
633
691
 
692
+ ensureSession(params: {
693
+ id: string;
694
+ projectRoot: string;
695
+ label?: string;
696
+ startedAt?: number;
697
+ }): void {
698
+ if (!params.id.trim()) throw new Error('Canonical session ID is required');
699
+ const startedAt = params.startedAt ?? Date.now();
700
+ this.db.prepare(`
701
+ INSERT INTO sessions (id, project_root, label, started_at, status)
702
+ VALUES (?, ?, ?, ?, 'running')
703
+ ON CONFLICT(id) DO UPDATE SET
704
+ project_root = CASE
705
+ WHEN sessions.project_root = '' OR sessions.project_root = 'unknown' THEN excluded.project_root
706
+ ELSE sessions.project_root
707
+ END,
708
+ label = COALESCE(sessions.label, excluded.label),
709
+ started_at = MIN(sessions.started_at, excluded.started_at)
710
+ `).run(params.id, params.projectRoot || 'unknown', params.label ?? null, startedAt);
711
+ }
712
+
634
713
  getSession(id: string): any | null {
635
714
  const row = this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as any;
636
715
  if (!row) return null;
@@ -646,6 +725,7 @@ export class DevFlowDatabase {
646
725
  directToolCalls: row.direct_tool_calls,
647
726
  subagentCount: row.subagent_count,
648
727
  status: row.status,
728
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
649
729
  };
650
730
  }
651
731
 
@@ -672,6 +752,7 @@ export class DevFlowDatabase {
672
752
  directToolCalls: row.direct_tool_calls,
673
753
  subagentCount: row.subagent_count,
674
754
  status: row.status,
755
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
675
756
  }));
676
757
  }
677
758
 
@@ -739,6 +820,7 @@ export class DevFlowDatabase {
739
820
  blocked: row.blocked === 1,
740
821
  blockReason: row.block_reason,
741
822
  failureCategory: row.failure_category,
823
+ toolUseId: row.tool_use_id,
742
824
  tokensUsed: row.tokens_used ?? 0,
743
825
  }));
744
826
  }
@@ -775,6 +857,7 @@ export class DevFlowDatabase {
775
857
  blocked: row.blocked === 1,
776
858
  blockReason: row.block_reason,
777
859
  failureCategory: row.failure_category,
860
+ toolUseId: row.tool_use_id,
778
861
  tokensUsed: row.tokens_used ?? 0,
779
862
  });
780
863
  }
@@ -827,6 +910,7 @@ export class DevFlowDatabase {
827
910
  mcpComplianceRate: row.mcp_compliance_rate,
828
911
  missedMcpTools: row.missed_mcp_tools ? JSON.parse(row.missed_mcp_tools) : null,
829
912
  createdAt: row.created_at,
913
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
830
914
  };
831
915
  }
832
916
 
@@ -840,6 +924,7 @@ export class DevFlowDatabase {
840
924
  mcpToolCalls?: number;
841
925
  directToolCalls?: number;
842
926
  subagentCount?: number;
927
+ metadata?: Record<string, unknown>;
843
928
  }): void {
844
929
  const sets: string[] = [];
845
930
  const values: any[] = [];
@@ -853,6 +938,7 @@ export class DevFlowDatabase {
853
938
  if (updates.mcpToolCalls !== undefined) { sets.push('mcp_tool_calls = ?'); values.push(updates.mcpToolCalls); }
854
939
  if (updates.directToolCalls !== undefined) { sets.push('direct_tool_calls = ?'); values.push(updates.directToolCalls); }
855
940
  if (updates.subagentCount !== undefined) { sets.push('subagent_count = ?'); values.push(updates.subagentCount); }
941
+ if (updates.metadata !== undefined) { sets.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
856
942
 
857
943
  if (sets.length > 0) {
858
944
  values.push(executionId);
@@ -886,6 +972,7 @@ export class DevFlowDatabase {
886
972
  subagentCount: row.subagent_count,
887
973
  totalDuration: row.total_duration,
888
974
  mcpComplianceRate: row.mcp_compliance_rate,
975
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
889
976
  }));
890
977
  }
891
978
 
@@ -914,6 +1001,7 @@ export class DevFlowDatabase {
914
1001
  workflowRunId?: string;
915
1002
  failureCategory?: string;
916
1003
  tokensUsed?: number;
1004
+ toolUseId?: string;
917
1005
  }): boolean {
918
1006
  return this.db.transaction(() => {
919
1007
  const result = this.db.prepare(`
@@ -921,8 +1009,8 @@ export class DevFlowDatabase {
921
1009
  (event_id, execution_id, session_id, timestamp, tool_name, tool_type, is_mcp_tool,
922
1010
  mcp_tool_name, mcp_enforced, mcp_fallback, kind, input, output, tokens_used, duration,
923
1011
  parent_tool_call_id, subagent_id, error, blocked, block_reason, workflow_run_id,
924
- failure_category)
925
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1012
+ failure_category, tool_use_id)
1013
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
926
1014
  `).run(
927
1015
  params.eventId,
928
1016
  params.executionId || '',
@@ -946,6 +1034,7 @@ export class DevFlowDatabase {
946
1034
  params.blockReason || null,
947
1035
  params.workflowRunId || null,
948
1036
  params.failureCategory || null,
1037
+ params.toolUseId || null,
949
1038
  );
950
1039
 
951
1040
  if (result.changes === 0) return false;
@@ -955,14 +1044,37 @@ export class DevFlowDatabase {
955
1044
  SET total_tool_calls = total_tool_calls + 1,
956
1045
  mcp_tool_calls = mcp_tool_calls + ?,
957
1046
  direct_tool_calls = direct_tool_calls + ?,
958
- subagent_count = subagent_count + ?
1047
+ subagent_count = subagent_count + ?,
1048
+ total_duration = MAX(total_duration, ? - started_at),
1049
+ mcp_compliance_rate = ROUND(
1050
+ 100.0 * (mcp_tool_calls + ?) / NULLIF(total_tool_calls + 1, 0), 2
1051
+ )
959
1052
  WHERE execution_id = ?
960
1053
  `).run(
961
1054
  params.isMcpTool ? 1 : 0,
962
1055
  params.isMcpTool ? 0 : 1,
963
1056
  params.toolType === 'subagent' ? 1 : 0,
1057
+ params.timestamp,
1058
+ params.isMcpTool ? 1 : 0,
964
1059
  params.executionId,
965
1060
  );
1061
+ if (params.sessionId) {
1062
+ this.db.prepare(`
1063
+ UPDATE sessions
1064
+ SET total_tool_calls = total_tool_calls + 1,
1065
+ mcp_tool_calls = mcp_tool_calls + ?,
1066
+ direct_tool_calls = direct_tool_calls + ?,
1067
+ subagent_count = subagent_count + ?,
1068
+ duration_ms = MAX(duration_ms, ? - started_at)
1069
+ WHERE id = ?
1070
+ `).run(
1071
+ params.isMcpTool ? 1 : 0,
1072
+ params.isMcpTool ? 0 : 1,
1073
+ params.toolType === 'subagent' ? 1 : 0,
1074
+ params.timestamp,
1075
+ params.sessionId,
1076
+ );
1077
+ }
966
1078
  return true;
967
1079
  });
968
1080
  }
@@ -993,6 +1105,7 @@ export class DevFlowDatabase {
993
1105
  blocked: row.blocked === 1,
994
1106
  blockReason: row.block_reason,
995
1107
  failureCategory: row.failure_category,
1108
+ toolUseId: row.tool_use_id,
996
1109
  }));
997
1110
  }
998
1111
 
@@ -1023,65 +1136,147 @@ export class DevFlowDatabase {
1023
1136
  return false;
1024
1137
  }
1025
1138
 
1139
+ markToolCallBlocked(eventId: string, reason: string): boolean {
1140
+ return this.db.prepare(`
1141
+ UPDATE tool_call_events
1142
+ SET blocked = 1, block_reason = ?, error = COALESCE(error, ?)
1143
+ WHERE event_id = ?
1144
+ `).run(reason, reason, eventId).changes > 0;
1145
+ }
1146
+
1147
+ reconcileSkillExecution(
1148
+ executionId: string,
1149
+ status: 'completed' | 'failed',
1150
+ finishedAt = Date.now(),
1151
+ metadata?: Record<string, unknown>,
1152
+ ): void {
1153
+ const row = this.db.prepare(`
1154
+ SELECT COUNT(*) AS total,
1155
+ SUM(CASE WHEN is_mcp_tool = 1 THEN 1 ELSE 0 END) AS mcp,
1156
+ SUM(CASE WHEN is_mcp_tool = 0 THEN 1 ELSE 0 END) AS direct,
1157
+ SUM(CASE WHEN tool_type = 'subagent' THEN 1 ELSE 0 END) AS subagents,
1158
+ MIN(timestamp) AS first_at,
1159
+ MAX(timestamp) AS last_at
1160
+ FROM tool_call_events WHERE execution_id = ?
1161
+ `).get(executionId) as any;
1162
+ const total = Number(row?.total ?? 0);
1163
+ const mcp = Number(row?.mcp ?? 0);
1164
+ const obligation = this.getExecutionObligationCompliance(executionId);
1165
+ this.updateSkillExecution(executionId, {
1166
+ status,
1167
+ finishedAt,
1168
+ totalToolCalls: total,
1169
+ mcpToolCalls: mcp,
1170
+ directToolCalls: Number(row?.direct ?? 0),
1171
+ subagentCount: Number(row?.subagents ?? 0),
1172
+ totalDuration: row?.first_at != null && row?.last_at != null ? row.last_at - row.first_at : 0,
1173
+ mcpComplianceRate: obligation.rate,
1174
+ metadata,
1175
+ });
1176
+ }
1177
+
1178
+ getExecutionObligationCompliance(executionId: string): {
1179
+ rate: number;
1180
+ applicable: number;
1181
+ satisfied: number;
1182
+ missedTools: string[];
1183
+ mcpCallShare: number;
1184
+ } {
1185
+ const execution = this.getSkillExecution(executionId);
1186
+ const events = this.listToolCallEvents(executionId);
1187
+ if (!execution) {
1188
+ return { rate: 0, applicable: 0, satisfied: 0, missedTools: [], mcpCallShare: 0 };
1189
+ }
1190
+ const obligations = obligationsForSkill(execution.skillName, execution.requiredMcpTools);
1191
+ const satisfied = obligations.filter((obligation) => {
1192
+ if (obligation.endsWith(':context')) {
1193
+ return events.some((event) => toolNameMatches(event, 'get_project_context'));
1194
+ }
1195
+ if (obligation.endsWith(':domain')) {
1196
+ const family = obligation.slice(0, -':domain'.length);
1197
+ return events.some((event) => {
1198
+ if (event.blocked || event.error || !event.isMcpTool) return false;
1199
+ const name = String(event.mcpToolName ?? event.toolName ?? '')
1200
+ .replace(/^mcp__[^_]+__/, '');
1201
+ return name.startsWith(`${family}_`);
1202
+ });
1203
+ }
1204
+ return events.some((event) => toolNameMatches(event, obligation));
1205
+ });
1206
+ const missedTools = obligations.filter((obligation) => !satisfied.includes(obligation));
1207
+ const total = events.length;
1208
+ const mcp = events.filter((event) => event.isMcpTool).length;
1209
+ return {
1210
+ rate: obligations.length > 0
1211
+ ? Math.round((satisfied.length / obligations.length) * 10000) / 100
1212
+ : 0,
1213
+ applicable: obligations.length,
1214
+ satisfied: satisfied.length,
1215
+ missedTools,
1216
+ mcpCallShare: total > 0 ? Math.round((mcp / total) * 10000) / 100 : 0,
1217
+ };
1218
+ }
1219
+
1026
1220
  // ---- MCP Compliance ----
1027
1221
 
1028
1222
  getMcpCompliance(): {
1029
1223
  overall: number;
1030
1224
  bySkill: Record<string, number>;
1031
1225
  missedTools: string[];
1226
+ applicableObligations: number;
1227
+ satisfiedObligations: number;
1228
+ mcpCallShare: number;
1032
1229
  } {
1033
- // Overall compliance (include all executions, not just completed)
1034
- const overallRow = this.db.prepare(`
1035
- SELECT
1036
- SUM(mcp_tool_calls) as mcp_calls,
1037
- SUM(total_tool_calls) as total_calls
1038
- FROM skill_executions
1039
- `).get() as any;
1040
-
1041
- const overall = overallRow?.total_calls > 0
1042
- ? Math.round((overallRow.mcp_calls / overallRow.total_calls) * 10000) / 100
1043
- : 0;
1044
-
1045
- // By skill
1046
- const bySkillRows = this.db.prepare(`
1047
- SELECT
1048
- skill_name,
1049
- SUM(mcp_tool_calls) as mcp_calls,
1050
- SUM(total_tool_calls) as total_calls
1051
- FROM skill_executions
1052
- GROUP BY skill_name
1053
- `).all() as any[];
1054
-
1230
+ const executions = this.db.prepare('SELECT execution_id, skill_name FROM skill_executions').all() as any[];
1055
1231
  const bySkill: Record<string, number> = {};
1056
- for (const row of bySkillRows) {
1057
- bySkill[row.skill_name] = row.total_calls > 0
1058
- ? Math.round((row.mcp_calls / row.total_calls) * 10000) / 100
1232
+ const bySkillCounts = new Map<string, { applicable: number; satisfied: number }>();
1233
+ let applicableObligations = 0;
1234
+ let satisfiedObligations = 0;
1235
+ let totalCalls = 0;
1236
+ let mcpCalls = 0;
1237
+ const missedTools = new Set<string>();
1238
+ for (const execution of executions) {
1239
+ const result = this.getExecutionObligationCompliance(execution.execution_id);
1240
+ const skill = String(execution.skill_name ?? 'unknown');
1241
+ const skillCounts = bySkillCounts.get(skill) ?? { applicable: 0, satisfied: 0 };
1242
+ skillCounts.applicable += result.applicable;
1243
+ skillCounts.satisfied += result.satisfied;
1244
+ bySkillCounts.set(skill, skillCounts);
1245
+ applicableObligations += result.applicable;
1246
+ satisfiedObligations += result.satisfied;
1247
+ result.missedTools.forEach((tool) => missedTools.add(tool));
1248
+ const events = this.listToolCallEvents(execution.execution_id);
1249
+ totalCalls += events.length;
1250
+ mcpCalls += events.filter((event) => event.isMcpTool).length;
1251
+ }
1252
+ for (const [skill, counts] of bySkillCounts) {
1253
+ bySkill[skill] = counts.applicable > 0
1254
+ ? Math.round((counts.satisfied / counts.applicable) * 10000) / 100
1059
1255
  : 0;
1060
1256
  }
1061
-
1062
- // Missed tools: tools that were required but fell back to direct
1063
- const missedRows = this.db.prepare(`
1064
- SELECT DISTINCT mcp_tool_name
1065
- FROM tool_call_events
1066
- WHERE mcp_fallback = 1 AND mcp_tool_name IS NOT NULL
1067
- ORDER BY timestamp DESC
1068
- LIMIT 10
1069
- `).all() as any[];
1070
-
1071
- const missedTools = missedRows.map((row: any) => row.mcp_tool_name);
1072
-
1073
- return { overall, bySkill, missedTools };
1257
+ return {
1258
+ overall: applicableObligations > 0
1259
+ ? Math.round((satisfiedObligations / applicableObligations) * 10000) / 100
1260
+ : 0,
1261
+ bySkill,
1262
+ missedTools: [...missedTools].slice(0, 10),
1263
+ applicableObligations,
1264
+ satisfiedObligations,
1265
+ mcpCallShare: totalCalls > 0 ? Math.round((mcpCalls / totalCalls) * 10000) / 100 : 0,
1266
+ };
1074
1267
  }
1075
1268
 
1076
1269
  cleanupStaleRecords(): void {
1077
1270
  const cutoff = Date.now() - 30 * 60 * 1000;
1078
1271
  try {
1079
1272
  this.db.prepare(
1080
- `UPDATE skill_executions SET status = 'failed', finished_at = ? WHERE status = 'running' AND started_at < ?`
1081
- ).run(Date.now(), cutoff);
1273
+ `UPDATE skill_executions SET status = 'failed', finished_at = ?, metadata = ?
1274
+ WHERE status = 'running' AND started_at < ?`
1275
+ ).run(Date.now(), JSON.stringify({ closureReason: 'abandoned' }), cutoff);
1082
1276
  this.db.prepare(
1083
- `UPDATE sessions SET status = 'failed', finished_at = ? WHERE status = 'running' AND started_at < ?`
1084
- ).run(Date.now(), cutoff);
1277
+ `UPDATE sessions SET status = 'failed', finished_at = ?, metadata = ?
1278
+ WHERE status = 'running' AND started_at < ?`
1279
+ ).run(Date.now(), JSON.stringify({ closureReason: 'abandoned' }), cutoff);
1085
1280
  } catch { /* best-effort */ }
1086
1281
  }
1087
1282
 
@@ -1581,6 +1776,60 @@ export class DevFlowDatabase {
1581
1776
  .run(projectRoot).changes > 0;
1582
1777
  }
1583
1778
 
1779
+ upsertContextReceipt(receipt: ContextReceiptRecord): void {
1780
+ this.db.prepare(`
1781
+ INSERT INTO devflow_context_receipts
1782
+ (project_root, session_id, execution_id, context_hash, issued_at, expires_at)
1783
+ VALUES (?, ?, ?, ?, ?, ?)
1784
+ ON CONFLICT(project_root, session_id, execution_id) DO UPDATE SET
1785
+ context_hash = excluded.context_hash,
1786
+ issued_at = excluded.issued_at,
1787
+ expires_at = excluded.expires_at
1788
+ `).run(
1789
+ receipt.projectRoot,
1790
+ receipt.sessionId,
1791
+ receipt.executionId,
1792
+ receipt.contextHash,
1793
+ receipt.issuedAt,
1794
+ receipt.expiresAt,
1795
+ );
1796
+ }
1797
+
1798
+ getContextReceipt(
1799
+ projectRoot: string,
1800
+ sessionId: string,
1801
+ executionId: string,
1802
+ ): ContextReceiptRecord | null {
1803
+ const row = this.db.prepare(`
1804
+ SELECT project_root, session_id, execution_id, context_hash, issued_at, expires_at
1805
+ FROM devflow_context_receipts
1806
+ WHERE project_root = ? AND session_id = ? AND execution_id = ?
1807
+ `).get(projectRoot, sessionId, executionId) as any;
1808
+ return row ? {
1809
+ projectRoot: row.project_root,
1810
+ sessionId: row.session_id,
1811
+ executionId: row.execution_id,
1812
+ contextHash: row.context_hash,
1813
+ issuedAt: row.issued_at,
1814
+ expiresAt: row.expires_at,
1815
+ } : null;
1816
+ }
1817
+
1818
+ deleteContextReceipt(projectRoot: string, sessionId: string, executionId?: string): number {
1819
+ return executionId
1820
+ ? this.db.prepare(`DELETE FROM devflow_context_receipts
1821
+ WHERE project_root = ? AND session_id = ? AND execution_id = ?`)
1822
+ .run(projectRoot, sessionId, executionId).changes
1823
+ : this.db.prepare(`DELETE FROM devflow_context_receipts
1824
+ WHERE project_root = ? AND session_id = ?`)
1825
+ .run(projectRoot, sessionId).changes;
1826
+ }
1827
+
1828
+ purgeExpiredContextReceipts(now = Date.now()): number {
1829
+ return this.db.prepare('DELETE FROM devflow_context_receipts WHERE expires_at <= ?')
1830
+ .run(now).changes;
1831
+ }
1832
+
1584
1833
  recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void {
1585
1834
  this.db.prepare(`
1586
1835
  INSERT INTO devflow_memory_distill_checkpoints