@devflow-tools/database 0.16.9 → 0.16.10

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/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.16.10](https://github.com/shilongfeicool/dev-flow/compare/v0.16.9...v0.16.10) (2026-07-24)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **telemetry:** persist compliance failure facts ([fcb5a8b](https://github.com/shilongfeicool/dev-flow/commit/fcb5a8b4db37fc545927262ae841669395491b06))
12
+
13
+
14
+ ### Features
15
+
16
+ * **memory:** add canonical turn and explicit receipts ([eb3e178](https://github.com/shilongfeicool/dev-flow/commit/eb3e1786e17f5dbc2c4e12e4fa788af7095d335e))
17
+
18
+
19
+
20
+
21
+
6
22
  ## [0.16.9](https://github.com/shilongfeicool/dev-flow/compare/v0.16.8...v0.16.9) (2026-07-23)
7
23
 
8
24
  **Note:** Version bump only for package @devflow-tools/database
@@ -0,0 +1,71 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { DevFlowDatabase } from '../src/database';
6
+
7
+ describe('DevFlowDatabase memory turn receipts', () => {
8
+ let directory: string;
9
+ let database: DevFlowDatabase;
10
+
11
+ beforeEach(() => {
12
+ directory = mkdtempSync(join(tmpdir(), 'devflow-memory-turn-'));
13
+ database = new DevFlowDatabase(directory);
14
+ });
15
+
16
+ afterEach(() => {
17
+ database.close();
18
+ rmSync(directory, { recursive: true, force: true });
19
+ });
20
+
21
+ it('commits a pending turn with one canonical receipt', () => {
22
+ database.beginMemoryTurn({
23
+ turnId: 'turn:1',
24
+ projectRoot: '/project',
25
+ sessionId: 'session-1',
26
+ promptHash: 'hash',
27
+ eventId: 'event-1',
28
+ createdAt: 100,
29
+ });
30
+
31
+ const committed = database.commitMemoryTurn({
32
+ turnId: 'turn:1',
33
+ receiptId: 'memory-receipt:1',
34
+ memoryIds: ['memory-1'],
35
+ source: 'explicit_intent',
36
+ decidedAt: 200,
37
+ });
38
+
39
+ expect(committed).toMatchObject({
40
+ status: 'committed',
41
+ receiptId: 'memory-receipt:1',
42
+ memoryIds: ['memory-1'],
43
+ stopPromptedAt: undefined,
44
+ });
45
+ expect(database.getPendingMemoryTurn('/project', 'session-1')).toBeNull();
46
+ });
47
+
48
+ it('marks a pending Stop prompt only once and supports skip receipts', () => {
49
+ database.beginMemoryTurn({
50
+ turnId: 'turn:2',
51
+ projectRoot: '/project',
52
+ sessionId: 'session-1',
53
+ promptHash: 'hash-2',
54
+ eventId: 'event-2',
55
+ createdAt: 300,
56
+ });
57
+
58
+ expect(database.markMemoryTurnStopPrompted('turn:2', 350)).toBe(true);
59
+ expect(database.markMemoryTurnStopPrompted('turn:2', 360)).toBe(false);
60
+ expect(database.skipMemoryTurn({
61
+ turnId: 'turn:2',
62
+ receiptId: 'memory-receipt:2',
63
+ reason: 'transient request',
64
+ decidedAt: 400,
65
+ })).toMatchObject({
66
+ status: 'skipped',
67
+ reason: 'transient request',
68
+ stopPromptedAt: 350,
69
+ });
70
+ });
71
+ });
@@ -154,19 +154,19 @@ describe('DevFlowDatabase - Skill Executions', () => {
154
154
  eventId: 'evt_context_1', executionId: 'exec_001', timestamp: Date.now(),
155
155
  toolName: 'mcp__devflow__get_project_context', toolType: 'mcp',
156
156
  isMcpTool: true, mcpToolName: 'get_project_context', mcpEnforced: true,
157
- mcpFallback: false, input: {}, duration: 20, blocked: false,
157
+ mcpFallback: false, input: {}, output: { files: ['src/App.tsx'] }, duration: 20, blocked: false,
158
158
  });
159
159
  db.insertToolCallEvent({
160
160
  eventId: 'evt_001', executionId: 'exec_001', timestamp: Date.now(),
161
161
  toolName: 'mcp__devflow__react_diagnose_bug', toolType: 'mcp',
162
162
  isMcpTool: true, mcpEnforced: true, mcpFallback: false,
163
- input: {}, tokensUsed: 100, duration: 500, blocked: false,
163
+ input: {}, output: { findings: [{ id: 'finding-1' }] }, tokensUsed: 100, duration: 500, blocked: false,
164
164
  });
165
165
  db.insertToolCallEvent({
166
166
  eventId: 'evt_002', executionId: 'exec_001', timestamp: Date.now(),
167
167
  toolName: 'mcp__devflow__react_review_hooks', toolType: 'mcp',
168
168
  isMcpTool: true, mcpEnforced: true, mcpFallback: false,
169
- input: {}, tokensUsed: 100, duration: 500, blocked: false,
169
+ input: {}, output: { findings: [{ id: 'finding-2' }] }, tokensUsed: 100, duration: 500, blocked: false,
170
170
  });
171
171
  db.insertToolCallEvent({
172
172
  eventId: 'evt_003', executionId: 'exec_001', timestamp: Date.now(),
@@ -186,13 +186,13 @@ describe('DevFlowDatabase - Skill Executions', () => {
186
186
  eventId: 'evt_context_2', executionId: 'exec_002', timestamp: Date.now(),
187
187
  toolName: 'mcp__devflow__get_project_context', toolType: 'mcp',
188
188
  isMcpTool: true, mcpToolName: 'get_project_context', mcpEnforced: true,
189
- mcpFallback: false, input: {}, duration: 20, blocked: false,
189
+ mcpFallback: false, input: {}, output: { files: ['src/App.vue'] }, duration: 20, blocked: false,
190
190
  });
191
191
  db.insertToolCallEvent({
192
192
  eventId: 'evt_004', executionId: 'exec_002', timestamp: Date.now(),
193
193
  toolName: 'mcp__devflow__vue_diagnose_bug', toolType: 'mcp',
194
194
  isMcpTool: true, mcpEnforced: true, mcpFallback: false,
195
- input: {}, tokensUsed: 100, duration: 500, blocked: false,
195
+ input: {}, output: { findings: [{ id: 'finding-vue' }] }, tokensUsed: 100, duration: 500, blocked: false,
196
196
  });
197
197
 
198
198
  const compliance = db.getMcpCompliance();
@@ -204,6 +204,61 @@ describe('DevFlowDatabase - Skill Executions', () => {
204
204
  expect(compliance.mcpCallShare).toBeCloseTo(83.33, 1);
205
205
  });
206
206
 
207
+ it('persists missed tools, actual failures, and fallback reasons on reconciliation', () => {
208
+ db.ensureSession({ id: 'session-facts', projectRoot: '/project', startedAt: 100 });
209
+ db.insertSkillExecution({
210
+ executionId: 'exec-facts',
211
+ sessionId: 'session-facts',
212
+ skillName: 'devflow:react',
213
+ startedAt: 100,
214
+ status: 'running',
215
+ });
216
+ db.insertToolCallEvent({
217
+ eventId: 'event-failed', executionId: 'exec-facts', sessionId: 'session-facts', timestamp: 150,
218
+ toolName: 'mcp__devflow__get_project_context', toolType: 'mcp', isMcpTool: true,
219
+ mcpToolName: 'get_project_context', mcpEnforced: true, mcpFallback: false,
220
+ input: {}, error: 'route failed', duration: 20, blocked: false,
221
+ });
222
+ db.insertToolCallEvent({
223
+ eventId: 'event-fallback', executionId: 'exec-facts', sessionId: 'session-facts', timestamp: 160,
224
+ toolName: 'Read', toolType: 'direct', isMcpTool: false, mcpEnforced: false,
225
+ mcpFallback: true, input: {}, output: { content: 'fallback' }, duration: 10, blocked: false,
226
+ });
227
+ db.insertToolCallEvent({
228
+ eventId: 'event-memory-receipt', executionId: 'exec-facts', sessionId: 'session-facts', timestamp: 165,
229
+ toolName: 'mcp__devflow__memory_commit_turn', toolType: 'mcp', isMcpTool: true,
230
+ mcpToolName: 'memory_commit_turn', mcpEnforced: true, mcpFallback: false,
231
+ input: {}, output: { receiptId: 'memory-receipt:host-commit' }, duration: 5, blocked: false,
232
+ });
233
+ db.insertToolCallEvent({
234
+ eventId: 'event-distill-receipt', executionId: 'exec-facts', sessionId: 'session-facts', timestamp: 166,
235
+ toolName: 'mcp__devflow__memory_save_distilled', toolType: 'mcp', isMcpTool: true,
236
+ mcpToolName: 'memory_save_distilled', mcpEnforced: true, mcpFallback: false,
237
+ input: {}, output: { receipt: { receiptId: 'distill-receipt:batch-1' } }, duration: 5, blocked: false,
238
+ });
239
+ db.insertHookFallback({
240
+ id: 'hook-fallback-1', projectRoot: '/project', sessionId: 'session-facts',
241
+ requestType: 'post-tool-use', tool: 'Read', reason: 'timeout', durationMs: 1_000,
242
+ attempts: 1, createdAt: 170,
243
+ });
244
+
245
+ db.reconcileSkillExecution('exec-facts', 'completed', 200);
246
+
247
+ expect(db.getSkillExecution('exec-facts')).toMatchObject({
248
+ missedMcpTools: ['react:context', 'react:domain'],
249
+ failedToolCalls: 1,
250
+ blockedToolCalls: 0,
251
+ fallbackCount: 2,
252
+ fallbackReasons: ['direct_tool_during_context', 'daemon_timeout'],
253
+ metadata: expect.objectContaining({
254
+ actualFailureCount: 1,
255
+ fallbackCount: 2,
256
+ memoryReceiptIds: ['memory-receipt:host-commit'],
257
+ distillReceiptIds: ['distill-receipt:batch-1'],
258
+ }),
259
+ });
260
+ });
261
+
207
262
  it('aggregates an empty findings collection as an empty result', () => {
208
263
  db.insertSkillExecution({
209
264
  executionId: 'exec_empty_findings',
@@ -83,6 +83,34 @@ export interface MemoryDistillCheckpointRecord {
83
83
  releasedLeases: number;
84
84
  createdAt: number;
85
85
  }
86
+ export type MemoryTurnStatus = 'pending' | 'committed' | 'skipped';
87
+ export interface MemoryTurnRecord {
88
+ turnId: string;
89
+ projectRoot: string;
90
+ sessionId: string;
91
+ promptHash: string;
92
+ eventId: string;
93
+ status: MemoryTurnStatus;
94
+ receiptId?: string;
95
+ memoryIds: string[];
96
+ source?: string;
97
+ reason?: string;
98
+ stopPromptedAt?: number;
99
+ createdAt: number;
100
+ decidedAt?: number;
101
+ }
102
+ export interface HookFallbackRecord {
103
+ id: string;
104
+ projectRoot: string;
105
+ sessionId?: string;
106
+ toolUseId?: string;
107
+ requestType: string;
108
+ tool: string;
109
+ reason: 'timeout' | 'unreachable' | 'protocol' | 'unknown';
110
+ durationMs: number;
111
+ attempts: number;
112
+ createdAt: number;
113
+ }
86
114
  export declare function getGlobalDevFlowDbPath(home?: string): string;
87
115
  export declare function openGlobalDevFlowDatabase(home?: string, options?: {
88
116
  busyTimeoutMs?: number;
@@ -145,6 +173,10 @@ export declare class DevFlowDatabase {
145
173
  totalDuration?: number;
146
174
  mcpComplianceRate?: number;
147
175
  missedMcpTools?: string[];
176
+ failedToolCalls?: number;
177
+ blockedToolCalls?: number;
178
+ fallbackCount?: number;
179
+ fallbackReasons?: string[];
148
180
  totalToolCalls?: number;
149
181
  mcpToolCalls?: number;
150
182
  directToolCalls?: number;
@@ -275,6 +307,34 @@ export declare class DevFlowDatabase {
275
307
  getContextReceipt(projectRoot: string, sessionId: string, executionId: string): ContextReceiptRecord | null;
276
308
  deleteContextReceipt(projectRoot: string, sessionId: string, executionId?: string): number;
277
309
  purgeExpiredContextReceipts(now?: number): number;
310
+ beginMemoryTurn(input: Omit<MemoryTurnRecord, 'status' | 'memoryIds'>): MemoryTurnRecord;
311
+ getMemoryTurn(turnId: string): MemoryTurnRecord | null;
312
+ getPendingMemoryTurn(projectRoot: string, sessionId: string): MemoryTurnRecord | null;
313
+ commitMemoryTurn(input: {
314
+ turnId: string;
315
+ receiptId: string;
316
+ memoryIds: string[];
317
+ source: string;
318
+ reason?: string;
319
+ decidedAt?: number;
320
+ }): MemoryTurnRecord;
321
+ skipMemoryTurn(input: {
322
+ turnId: string;
323
+ receiptId: string;
324
+ reason: string;
325
+ decidedAt?: number;
326
+ }): MemoryTurnRecord;
327
+ markMemoryTurnStopPrompted(turnId: string, promptedAt?: number): boolean;
328
+ listMemoryTurns(projectRoot: string, sessionId?: string, limit?: number): MemoryTurnRecord[];
329
+ insertHookFallback(record: HookFallbackRecord): boolean;
330
+ listHookFallbacks(filter?: {
331
+ projectRoot?: string;
332
+ sessionId?: string;
333
+ from?: number;
334
+ to?: number;
335
+ limit?: number;
336
+ }): HookFallbackRecord[];
337
+ private mapMemoryTurn;
278
338
  recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void;
279
339
  listMemoryDistillCheckpoints(projectRoot: string, limit?: number): MemoryDistillCheckpointRecord[];
280
340
  all(sql: string, ...params: unknown[]): unknown[];
package/dist/database.js CHANGED
@@ -11,12 +11,22 @@ const CONTEXT_REQUIRED_SKILLS = new Set([
11
11
  'react', 'vue', 'nest', 'nextjs', 'graphql', 'typescript',
12
12
  ]);
13
13
  function toolNameMatches(event, expected) {
14
- if (event.blocked || event.error)
14
+ if (!isEffectiveToolEvent(event))
15
15
  return false;
16
16
  const name = String(event.mcpToolName ?? event.toolName ?? '')
17
17
  .replace(/^mcp__[^_]+__/, '');
18
18
  return name === expected;
19
19
  }
20
+ function isEffectiveToolEvent(event) {
21
+ if (event.blocked || event.error || event.output == null)
22
+ return false;
23
+ if (typeof event.output === 'object') {
24
+ const output = event.output;
25
+ if (output.error || output.isError === true || output.success === false || output.degraded === true)
26
+ return false;
27
+ }
28
+ return countTelemetryResults(event.output) > 0;
29
+ }
20
30
  function obligationsForSkill(skillName, configured) {
21
31
  const configuredTools = Array.isArray(configured)
22
32
  ? configured.filter((tool) => typeof tool === 'string' && tool.length > 0)
@@ -145,6 +155,10 @@ class DevFlowDatabase {
145
155
  total_duration INTEGER DEFAULT 0,
146
156
  mcp_compliance_rate REAL DEFAULT 0,
147
157
  missed_mcp_tools TEXT,
158
+ failed_tool_calls INTEGER DEFAULT 0,
159
+ blocked_tool_calls INTEGER DEFAULT 0,
160
+ fallback_count INTEGER DEFAULT 0,
161
+ fallback_reasons TEXT,
148
162
  created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000)
149
163
  );
150
164
 
@@ -284,6 +298,43 @@ class DevFlowDatabase {
284
298
 
285
299
  CREATE INDEX IF NOT EXISTS idx_distill_checkpoints_project
286
300
  ON devflow_memory_distill_checkpoints(project_root, created_at DESC);
301
+
302
+ CREATE TABLE IF NOT EXISTS devflow_memory_turns (
303
+ turn_id TEXT PRIMARY KEY,
304
+ project_root TEXT NOT NULL,
305
+ session_id TEXT NOT NULL,
306
+ prompt_hash TEXT NOT NULL,
307
+ event_id TEXT NOT NULL,
308
+ status TEXT NOT NULL DEFAULT 'pending'
309
+ CHECK(status IN ('pending', 'committed', 'skipped')),
310
+ receipt_id TEXT,
311
+ memory_ids TEXT NOT NULL DEFAULT '[]',
312
+ source TEXT,
313
+ reason TEXT,
314
+ stop_prompted_at INTEGER,
315
+ created_at INTEGER NOT NULL,
316
+ decided_at INTEGER
317
+ );
318
+
319
+ CREATE INDEX IF NOT EXISTS idx_memory_turns_pending
320
+ ON devflow_memory_turns(project_root, session_id, status, created_at DESC);
321
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_turns_receipt
322
+ ON devflow_memory_turns(receipt_id) WHERE receipt_id IS NOT NULL;
323
+
324
+ CREATE TABLE IF NOT EXISTS devflow_hook_fallbacks (
325
+ id TEXT PRIMARY KEY,
326
+ project_root TEXT NOT NULL,
327
+ session_id TEXT,
328
+ tool_use_id TEXT,
329
+ request_type TEXT NOT NULL,
330
+ tool TEXT NOT NULL,
331
+ reason TEXT NOT NULL CHECK(reason IN ('timeout', 'unreachable', 'protocol', 'unknown')),
332
+ duration_ms INTEGER NOT NULL,
333
+ attempts INTEGER NOT NULL,
334
+ created_at INTEGER NOT NULL
335
+ );
336
+ CREATE INDEX IF NOT EXISTS idx_hook_fallbacks_session
337
+ ON devflow_hook_fallbacks(project_root, session_id, created_at DESC);
287
338
  `);
288
339
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
289
340
  try {
@@ -326,6 +377,22 @@ class DevFlowDatabase {
326
377
  this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT');
327
378
  }
328
379
  catch { }
380
+ try {
381
+ this.db.exec('ALTER TABLE skill_executions ADD COLUMN failed_tool_calls INTEGER DEFAULT 0');
382
+ }
383
+ catch { }
384
+ try {
385
+ this.db.exec('ALTER TABLE skill_executions ADD COLUMN blocked_tool_calls INTEGER DEFAULT 0');
386
+ }
387
+ catch { }
388
+ try {
389
+ this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_count INTEGER DEFAULT 0');
390
+ }
391
+ catch { }
392
+ try {
393
+ this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_reasons TEXT');
394
+ }
395
+ catch { }
329
396
  try {
330
397
  this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_events_session_tool_use
331
398
  ON tool_call_events(session_id, tool_use_id) WHERE tool_use_id IS NOT NULL`);
@@ -765,6 +832,10 @@ class DevFlowDatabase {
765
832
  totalDuration: row.total_duration,
766
833
  mcpComplianceRate: row.mcp_compliance_rate,
767
834
  missedMcpTools: row.missed_mcp_tools ? JSON.parse(row.missed_mcp_tools) : null,
835
+ failedToolCalls: row.failed_tool_calls ?? 0,
836
+ blockedToolCalls: row.blocked_tool_calls ?? 0,
837
+ fallbackCount: row.fallback_count ?? 0,
838
+ fallbackReasons: row.fallback_reasons ? JSON.parse(row.fallback_reasons) : [],
768
839
  createdAt: row.created_at,
769
840
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
770
841
  };
@@ -792,6 +863,22 @@ class DevFlowDatabase {
792
863
  sets.push('missed_mcp_tools = ?');
793
864
  values.push(JSON.stringify(updates.missedMcpTools));
794
865
  }
866
+ if (updates.failedToolCalls !== undefined) {
867
+ sets.push('failed_tool_calls = ?');
868
+ values.push(updates.failedToolCalls);
869
+ }
870
+ if (updates.blockedToolCalls !== undefined) {
871
+ sets.push('blocked_tool_calls = ?');
872
+ values.push(updates.blockedToolCalls);
873
+ }
874
+ if (updates.fallbackCount !== undefined) {
875
+ sets.push('fallback_count = ?');
876
+ values.push(updates.fallbackCount);
877
+ }
878
+ if (updates.fallbackReasons !== undefined) {
879
+ sets.push('fallback_reasons = ?');
880
+ values.push(JSON.stringify(updates.fallbackReasons));
881
+ }
795
882
  if (updates.totalToolCalls !== undefined) {
796
883
  sets.push('total_tool_calls = ?');
797
884
  values.push(updates.totalToolCalls);
@@ -840,6 +927,11 @@ class DevFlowDatabase {
840
927
  subagentCount: row.subagent_count,
841
928
  totalDuration: row.total_duration,
842
929
  mcpComplianceRate: row.mcp_compliance_rate,
930
+ missedMcpTools: row.missed_mcp_tools ? JSON.parse(row.missed_mcp_tools) : [],
931
+ failedToolCalls: row.failed_tool_calls ?? 0,
932
+ blockedToolCalls: row.blocked_tool_calls ?? 0,
933
+ fallbackCount: row.fallback_count ?? 0,
934
+ fallbackReasons: row.fallback_reasons ? JSON.parse(row.fallback_reasons) : [],
843
935
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
844
936
  }));
845
937
  }
@@ -948,9 +1040,39 @@ class DevFlowDatabase {
948
1040
  FROM tool_call_events WHERE execution_id = ?
949
1041
  `).get(executionId);
950
1042
  const execution = this.getSkillExecution(executionId);
1043
+ const events = this.listToolCallEvents(executionId);
951
1044
  const total = Number(row?.total ?? 0);
952
1045
  const mcp = Number(row?.mcp ?? 0);
953
1046
  const obligation = this.getExecutionObligationCompliance(executionId);
1047
+ const failures = events.filter(event => Boolean(event.error) && !event.blocked);
1048
+ const blocked = events.filter(event => event.blocked);
1049
+ const directFallbacks = events.filter(event => event.mcpFallback);
1050
+ const hookFallbacks = execution?.sessionId
1051
+ ? this.listHookFallbacks({
1052
+ sessionId: execution.sessionId,
1053
+ from: execution.startedAt,
1054
+ to: finishedAt,
1055
+ })
1056
+ : [];
1057
+ const fallbackReasons = [...new Set([
1058
+ ...directFallbacks.map(() => 'direct_tool_during_context'),
1059
+ ...hookFallbacks.map(fallback => `daemon_${fallback.reason}`),
1060
+ ])];
1061
+ const eventReceiptIds = collectCanonicalReceiptIds(events.map(event => event.output));
1062
+ const suppliedMemoryReceiptIds = Array.isArray(metadata?.memoryReceiptIds)
1063
+ ? metadata.memoryReceiptIds.filter((id) => typeof id === 'string')
1064
+ : [];
1065
+ const suppliedDistillReceiptIds = Array.isArray(metadata?.distillReceiptIds)
1066
+ ? metadata.distillReceiptIds.filter((id) => typeof id === 'string')
1067
+ : [];
1068
+ const memoryReceiptIds = [...new Set([
1069
+ ...suppliedMemoryReceiptIds,
1070
+ ...eventReceiptIds.filter(id => id.startsWith('memory-receipt:')),
1071
+ ])];
1072
+ const distillReceiptIds = [...new Set([
1073
+ ...suppliedDistillReceiptIds,
1074
+ ...eventReceiptIds.filter(id => id.startsWith('distill-receipt:')),
1075
+ ])];
954
1076
  this.updateSkillExecution(executionId, {
955
1077
  status,
956
1078
  finishedAt,
@@ -962,7 +1084,23 @@ class DevFlowDatabase {
962
1084
  ? Math.max(0, finishedAt - execution.startedAt)
963
1085
  : row?.first_at != null && row?.last_at != null ? row.last_at - row.first_at : 0,
964
1086
  mcpComplianceRate: obligation.rate,
965
- metadata,
1087
+ missedMcpTools: obligation.missedTools,
1088
+ failedToolCalls: failures.length,
1089
+ blockedToolCalls: blocked.length,
1090
+ fallbackCount: directFallbacks.length + hookFallbacks.length,
1091
+ fallbackReasons,
1092
+ metadata: {
1093
+ ...(metadata ?? {}),
1094
+ applicableObligations: obligation.applicable,
1095
+ satisfiedObligations: obligation.satisfied,
1096
+ missedTools: obligation.missedTools,
1097
+ actualFailureCount: failures.length,
1098
+ blockedCount: blocked.length,
1099
+ fallbackCount: directFallbacks.length + hookFallbacks.length,
1100
+ fallbackReasons,
1101
+ memoryReceiptIds,
1102
+ distillReceiptIds,
1103
+ },
966
1104
  });
967
1105
  }
968
1106
  getExecutionObligationCompliance(executionId) {
@@ -979,7 +1117,7 @@ class DevFlowDatabase {
979
1117
  if (obligation.endsWith(':domain')) {
980
1118
  const family = obligation.slice(0, -':domain'.length);
981
1119
  return events.some((event) => {
982
- if (event.blocked || event.error || !event.isMcpTool)
1120
+ if (!event.isMcpTool || !isEffectiveToolEvent(event))
983
1121
  return false;
984
1122
  const name = String(event.mcpToolName ?? event.toolName ?? '')
985
1123
  .replace(/^mcp__[^_]+__/, '');
@@ -1477,6 +1615,137 @@ class DevFlowDatabase {
1477
1615
  return this.db.prepare('DELETE FROM devflow_context_receipts WHERE expires_at <= ?')
1478
1616
  .run(now).changes;
1479
1617
  }
1618
+ beginMemoryTurn(input) {
1619
+ this.db.prepare(`
1620
+ INSERT OR IGNORE INTO devflow_memory_turns
1621
+ (turn_id, project_root, session_id, prompt_hash, event_id, status, memory_ids, created_at)
1622
+ VALUES (?, ?, ?, ?, ?, 'pending', '[]', ?)
1623
+ `).run(input.turnId, input.projectRoot, input.sessionId, input.promptHash, input.eventId, input.createdAt);
1624
+ return this.getMemoryTurn(input.turnId);
1625
+ }
1626
+ getMemoryTurn(turnId) {
1627
+ const row = this.db.prepare('SELECT * FROM devflow_memory_turns WHERE turn_id = ?').get(turnId);
1628
+ return row ? this.mapMemoryTurn(row) : null;
1629
+ }
1630
+ getPendingMemoryTurn(projectRoot, sessionId) {
1631
+ const row = this.db.prepare(`
1632
+ SELECT * FROM devflow_memory_turns
1633
+ WHERE project_root = ? AND session_id = ? AND status = 'pending'
1634
+ ORDER BY created_at DESC LIMIT 1
1635
+ `).get(projectRoot, sessionId);
1636
+ return row ? this.mapMemoryTurn(row) : null;
1637
+ }
1638
+ commitMemoryTurn(input) {
1639
+ this.db.prepare(`
1640
+ UPDATE devflow_memory_turns
1641
+ SET status = 'committed', receipt_id = ?, memory_ids = ?, source = ?, reason = ?, decided_at = ?
1642
+ WHERE turn_id = ? AND status = 'pending'
1643
+ `).run(input.receiptId, JSON.stringify([...new Set(input.memoryIds)]), input.source, input.reason ?? null, input.decidedAt ?? Date.now(), input.turnId);
1644
+ const turn = this.getMemoryTurn(input.turnId);
1645
+ if (!turn || turn.status !== 'committed') {
1646
+ throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
1647
+ }
1648
+ return turn;
1649
+ }
1650
+ skipMemoryTurn(input) {
1651
+ this.db.prepare(`
1652
+ UPDATE devflow_memory_turns
1653
+ SET status = 'skipped', receipt_id = ?, memory_ids = '[]', source = 'host_skip',
1654
+ reason = ?, decided_at = ?
1655
+ WHERE turn_id = ? AND status = 'pending'
1656
+ `).run(input.receiptId, input.reason, input.decidedAt ?? Date.now(), input.turnId);
1657
+ const turn = this.getMemoryTurn(input.turnId);
1658
+ if (!turn || turn.status !== 'skipped') {
1659
+ throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
1660
+ }
1661
+ return turn;
1662
+ }
1663
+ markMemoryTurnStopPrompted(turnId, promptedAt = Date.now()) {
1664
+ return this.db.prepare(`
1665
+ UPDATE devflow_memory_turns SET stop_prompted_at = ?
1666
+ WHERE turn_id = ? AND status = 'pending' AND stop_prompted_at IS NULL
1667
+ `).run(promptedAt, turnId).changes === 1;
1668
+ }
1669
+ listMemoryTurns(projectRoot, sessionId, limit = 50) {
1670
+ const rows = (sessionId
1671
+ ? this.db.prepare(`SELECT * FROM devflow_memory_turns
1672
+ WHERE project_root = ? AND session_id = ? ORDER BY created_at DESC LIMIT ?`)
1673
+ .all(projectRoot, sessionId, limit)
1674
+ : this.db.prepare(`SELECT * FROM devflow_memory_turns
1675
+ WHERE project_root = ? ORDER BY created_at DESC LIMIT ?`)
1676
+ .all(projectRoot, limit));
1677
+ return rows.map(row => this.mapMemoryTurn(row));
1678
+ }
1679
+ insertHookFallback(record) {
1680
+ return this.db.prepare(`
1681
+ INSERT OR IGNORE INTO devflow_hook_fallbacks
1682
+ (id, project_root, session_id, tool_use_id, request_type, tool, reason,
1683
+ duration_ms, attempts, created_at)
1684
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1685
+ `).run(record.id, record.projectRoot, record.sessionId ?? null, record.toolUseId ?? null, record.requestType, record.tool, record.reason, record.durationMs, record.attempts, record.createdAt).changes === 1;
1686
+ }
1687
+ listHookFallbacks(filter = {}) {
1688
+ const conditions = [];
1689
+ const values = [];
1690
+ if (filter.projectRoot) {
1691
+ conditions.push('project_root = ?');
1692
+ values.push(filter.projectRoot);
1693
+ }
1694
+ if (filter.sessionId) {
1695
+ conditions.push('session_id = ?');
1696
+ values.push(filter.sessionId);
1697
+ }
1698
+ if (filter.from !== undefined) {
1699
+ conditions.push('created_at >= ?');
1700
+ values.push(filter.from);
1701
+ }
1702
+ if (filter.to !== undefined) {
1703
+ conditions.push('created_at <= ?');
1704
+ values.push(filter.to);
1705
+ }
1706
+ values.push(Math.max(1, Math.min(filter.limit ?? 500, 5000)));
1707
+ const rows = this.db.prepare(`
1708
+ SELECT * FROM devflow_hook_fallbacks
1709
+ ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
1710
+ ORDER BY created_at DESC LIMIT ?
1711
+ `).all(...values);
1712
+ return rows.map(row => ({
1713
+ id: row.id,
1714
+ projectRoot: row.project_root,
1715
+ sessionId: row.session_id ?? undefined,
1716
+ toolUseId: row.tool_use_id ?? undefined,
1717
+ requestType: row.request_type,
1718
+ tool: row.tool,
1719
+ reason: row.reason,
1720
+ durationMs: row.duration_ms,
1721
+ attempts: row.attempts,
1722
+ createdAt: row.created_at,
1723
+ }));
1724
+ }
1725
+ mapMemoryTurn(row) {
1726
+ let memoryIds = [];
1727
+ try {
1728
+ const parsed = JSON.parse(row.memory_ids ?? '[]');
1729
+ if (Array.isArray(parsed))
1730
+ memoryIds = parsed.filter((id) => typeof id === 'string');
1731
+ }
1732
+ catch { }
1733
+ return {
1734
+ turnId: row.turn_id,
1735
+ projectRoot: row.project_root,
1736
+ sessionId: row.session_id,
1737
+ promptHash: row.prompt_hash,
1738
+ eventId: row.event_id,
1739
+ status: row.status,
1740
+ receiptId: row.receipt_id ?? undefined,
1741
+ memoryIds,
1742
+ source: row.source ?? undefined,
1743
+ reason: row.reason ?? undefined,
1744
+ stopPromptedAt: row.stop_prompted_at ?? undefined,
1745
+ createdAt: row.created_at,
1746
+ decidedAt: row.decided_at ?? undefined,
1747
+ };
1748
+ }
1480
1749
  recordMemoryDistillCheckpoint(checkpoint) {
1481
1750
  this.db.prepare(`
1482
1751
  INSERT INTO devflow_memory_distill_checkpoints
@@ -1570,3 +1839,32 @@ function countTelemetryResults(value, depth = 0) {
1570
1839
  return 0;
1571
1840
  return Object.keys(record).length > 0 ? 1 : 0;
1572
1841
  }
1842
+ function collectCanonicalReceiptIds(values) {
1843
+ const receiptIds = new Set();
1844
+ const visit = (value, depth) => {
1845
+ if (depth > 5 || value == null)
1846
+ return;
1847
+ if (typeof value === 'string') {
1848
+ if (value.startsWith('memory-receipt:') || value.startsWith('distill-receipt:')) {
1849
+ receiptIds.add(value);
1850
+ return;
1851
+ }
1852
+ if ((value.startsWith('{') || value.startsWith('[')) && value.length < 1000000) {
1853
+ try {
1854
+ visit(JSON.parse(value), depth + 1);
1855
+ }
1856
+ catch { }
1857
+ }
1858
+ return;
1859
+ }
1860
+ if (Array.isArray(value)) {
1861
+ value.forEach(item => visit(item, depth + 1));
1862
+ return;
1863
+ }
1864
+ if (typeof value === 'object') {
1865
+ Object.values(value).forEach(item => visit(item, depth + 1));
1866
+ }
1867
+ };
1868
+ values.forEach(value => visit(value, 0));
1869
+ return [...receiptIds];
1870
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { DevFlowDatabase, getGlobalDevFlowDbPath, openGlobalDevFlowDatabase, } from './database';
2
- export type { BenchmarkReportMetaRecord, BenchmarkReportRecord, FeedbackRecord, GovernanceAuditRecord, GovernanceRuleRecord, HookReceiptRecord, MemoryDistillCheckpointRecord, TelemetryFailureRecord, } from './database';
2
+ export type { BenchmarkReportMetaRecord, BenchmarkReportRecord, FeedbackRecord, GovernanceAuditRecord, GovernanceRuleRecord, HookFallbackRecord, HookReceiptRecord, MemoryDistillCheckpointRecord, MemoryTurnRecord, MemoryTurnStatus, TelemetryFailureRecord, } from './database';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devflow-tools/database",
3
- "version": "0.16.9",
3
+ "version": "0.16.10",
4
4
  "description": "DevFlow SQLite database package",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/database.ts CHANGED
@@ -76,12 +76,21 @@ const CONTEXT_REQUIRED_SKILLS = new Set([
76
76
  ]);
77
77
 
78
78
  function toolNameMatches(event: any, expected: string): boolean {
79
- if (event.blocked || event.error) return false;
79
+ if (!isEffectiveToolEvent(event)) return false;
80
80
  const name = String(event.mcpToolName ?? event.toolName ?? '')
81
81
  .replace(/^mcp__[^_]+__/, '');
82
82
  return name === expected;
83
83
  }
84
84
 
85
+ function isEffectiveToolEvent(event: any): boolean {
86
+ if (event.blocked || event.error || event.output == null) return false;
87
+ if (typeof event.output === 'object') {
88
+ const output = event.output as Record<string, unknown>;
89
+ if (output.error || output.isError === true || output.success === false || output.degraded === true) return false;
90
+ }
91
+ return countTelemetryResults(event.output) > 0;
92
+ }
93
+
85
94
  function obligationsForSkill(skillName: string, configured: unknown): string[] {
86
95
  const configuredTools = Array.isArray(configured)
87
96
  ? configured.filter((tool): tool is string => typeof tool === 'string' && tool.length > 0)
@@ -124,6 +133,37 @@ export interface MemoryDistillCheckpointRecord {
124
133
  createdAt: number;
125
134
  }
126
135
 
136
+ export type MemoryTurnStatus = 'pending' | 'committed' | 'skipped';
137
+
138
+ export interface MemoryTurnRecord {
139
+ turnId: string;
140
+ projectRoot: string;
141
+ sessionId: string;
142
+ promptHash: string;
143
+ eventId: string;
144
+ status: MemoryTurnStatus;
145
+ receiptId?: string;
146
+ memoryIds: string[];
147
+ source?: string;
148
+ reason?: string;
149
+ stopPromptedAt?: number;
150
+ createdAt: number;
151
+ decidedAt?: number;
152
+ }
153
+
154
+ export interface HookFallbackRecord {
155
+ id: string;
156
+ projectRoot: string;
157
+ sessionId?: string;
158
+ toolUseId?: string;
159
+ requestType: string;
160
+ tool: string;
161
+ reason: 'timeout' | 'unreachable' | 'protocol' | 'unknown';
162
+ durationMs: number;
163
+ attempts: number;
164
+ createdAt: number;
165
+ }
166
+
127
167
  export function getGlobalDevFlowDbPath(home = homedir()): string {
128
168
  const stateDir = process.env.DEVFLOW_STATE_DIR ?? join(home, '.devflow', 'global');
129
169
  return join(stateDir, 'devflow.db');
@@ -239,6 +279,10 @@ export class DevFlowDatabase {
239
279
  total_duration INTEGER DEFAULT 0,
240
280
  mcp_compliance_rate REAL DEFAULT 0,
241
281
  missed_mcp_tools TEXT,
282
+ failed_tool_calls INTEGER DEFAULT 0,
283
+ blocked_tool_calls INTEGER DEFAULT 0,
284
+ fallback_count INTEGER DEFAULT 0,
285
+ fallback_reasons TEXT,
242
286
  created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000)
243
287
  );
244
288
 
@@ -378,6 +422,43 @@ export class DevFlowDatabase {
378
422
 
379
423
  CREATE INDEX IF NOT EXISTS idx_distill_checkpoints_project
380
424
  ON devflow_memory_distill_checkpoints(project_root, created_at DESC);
425
+
426
+ CREATE TABLE IF NOT EXISTS devflow_memory_turns (
427
+ turn_id TEXT PRIMARY KEY,
428
+ project_root TEXT NOT NULL,
429
+ session_id TEXT NOT NULL,
430
+ prompt_hash TEXT NOT NULL,
431
+ event_id TEXT NOT NULL,
432
+ status TEXT NOT NULL DEFAULT 'pending'
433
+ CHECK(status IN ('pending', 'committed', 'skipped')),
434
+ receipt_id TEXT,
435
+ memory_ids TEXT NOT NULL DEFAULT '[]',
436
+ source TEXT,
437
+ reason TEXT,
438
+ stop_prompted_at INTEGER,
439
+ created_at INTEGER NOT NULL,
440
+ decided_at INTEGER
441
+ );
442
+
443
+ CREATE INDEX IF NOT EXISTS idx_memory_turns_pending
444
+ ON devflow_memory_turns(project_root, session_id, status, created_at DESC);
445
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_turns_receipt
446
+ ON devflow_memory_turns(receipt_id) WHERE receipt_id IS NOT NULL;
447
+
448
+ CREATE TABLE IF NOT EXISTS devflow_hook_fallbacks (
449
+ id TEXT PRIMARY KEY,
450
+ project_root TEXT NOT NULL,
451
+ session_id TEXT,
452
+ tool_use_id TEXT,
453
+ request_type TEXT NOT NULL,
454
+ tool TEXT NOT NULL,
455
+ reason TEXT NOT NULL CHECK(reason IN ('timeout', 'unreachable', 'protocol', 'unknown')),
456
+ duration_ms INTEGER NOT NULL,
457
+ attempts INTEGER NOT NULL,
458
+ created_at INTEGER NOT NULL
459
+ );
460
+ CREATE INDEX IF NOT EXISTS idx_hook_fallbacks_session
461
+ ON devflow_hook_fallbacks(project_root, session_id, created_at DESC);
381
462
  `);
382
463
 
383
464
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
@@ -394,6 +475,10 @@ export class DevFlowDatabase {
394
475
  try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN tool_use_id TEXT'); } catch {}
395
476
  try { this.db.exec('ALTER TABLE sessions ADD COLUMN metadata TEXT'); } catch {}
396
477
  try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT'); } catch {}
478
+ try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN failed_tool_calls INTEGER DEFAULT 0'); } catch {}
479
+ try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN blocked_tool_calls INTEGER DEFAULT 0'); } catch {}
480
+ try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_count INTEGER DEFAULT 0'); } catch {}
481
+ try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_reasons TEXT'); } catch {}
397
482
  try {
398
483
  this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_events_session_tool_use
399
484
  ON tool_call_events(session_id, tool_use_id) WHERE tool_use_id IS NOT NULL`);
@@ -925,6 +1010,10 @@ export class DevFlowDatabase {
925
1010
  totalDuration: row.total_duration,
926
1011
  mcpComplianceRate: row.mcp_compliance_rate,
927
1012
  missedMcpTools: row.missed_mcp_tools ? JSON.parse(row.missed_mcp_tools) : null,
1013
+ failedToolCalls: row.failed_tool_calls ?? 0,
1014
+ blockedToolCalls: row.blocked_tool_calls ?? 0,
1015
+ fallbackCount: row.fallback_count ?? 0,
1016
+ fallbackReasons: row.fallback_reasons ? JSON.parse(row.fallback_reasons) : [],
928
1017
  createdAt: row.created_at,
929
1018
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
930
1019
  };
@@ -936,6 +1025,10 @@ export class DevFlowDatabase {
936
1025
  totalDuration?: number;
937
1026
  mcpComplianceRate?: number;
938
1027
  missedMcpTools?: string[];
1028
+ failedToolCalls?: number;
1029
+ blockedToolCalls?: number;
1030
+ fallbackCount?: number;
1031
+ fallbackReasons?: string[];
939
1032
  totalToolCalls?: number;
940
1033
  mcpToolCalls?: number;
941
1034
  directToolCalls?: number;
@@ -950,6 +1043,10 @@ export class DevFlowDatabase {
950
1043
  if (updates.totalDuration !== undefined) { sets.push('total_duration = ?'); values.push(updates.totalDuration); }
951
1044
  if (updates.mcpComplianceRate !== undefined) { sets.push('mcp_compliance_rate = ?'); values.push(updates.mcpComplianceRate); }
952
1045
  if (updates.missedMcpTools !== undefined) { sets.push('missed_mcp_tools = ?'); values.push(JSON.stringify(updates.missedMcpTools)); }
1046
+ if (updates.failedToolCalls !== undefined) { sets.push('failed_tool_calls = ?'); values.push(updates.failedToolCalls); }
1047
+ if (updates.blockedToolCalls !== undefined) { sets.push('blocked_tool_calls = ?'); values.push(updates.blockedToolCalls); }
1048
+ if (updates.fallbackCount !== undefined) { sets.push('fallback_count = ?'); values.push(updates.fallbackCount); }
1049
+ if (updates.fallbackReasons !== undefined) { sets.push('fallback_reasons = ?'); values.push(JSON.stringify(updates.fallbackReasons)); }
953
1050
  if (updates.totalToolCalls !== undefined) { sets.push('total_tool_calls = ?'); values.push(updates.totalToolCalls); }
954
1051
  if (updates.mcpToolCalls !== undefined) { sets.push('mcp_tool_calls = ?'); values.push(updates.mcpToolCalls); }
955
1052
  if (updates.directToolCalls !== undefined) { sets.push('direct_tool_calls = ?'); values.push(updates.directToolCalls); }
@@ -988,6 +1085,11 @@ export class DevFlowDatabase {
988
1085
  subagentCount: row.subagent_count,
989
1086
  totalDuration: row.total_duration,
990
1087
  mcpComplianceRate: row.mcp_compliance_rate,
1088
+ missedMcpTools: row.missed_mcp_tools ? JSON.parse(row.missed_mcp_tools) : [],
1089
+ failedToolCalls: row.failed_tool_calls ?? 0,
1090
+ blockedToolCalls: row.blocked_tool_calls ?? 0,
1091
+ fallbackCount: row.fallback_count ?? 0,
1092
+ fallbackReasons: row.fallback_reasons ? JSON.parse(row.fallback_reasons) : [],
991
1093
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
992
1094
  }));
993
1095
  }
@@ -1176,9 +1278,39 @@ export class DevFlowDatabase {
1176
1278
  FROM tool_call_events WHERE execution_id = ?
1177
1279
  `).get(executionId) as any;
1178
1280
  const execution = this.getSkillExecution(executionId);
1281
+ const events = this.listToolCallEvents(executionId);
1179
1282
  const total = Number(row?.total ?? 0);
1180
1283
  const mcp = Number(row?.mcp ?? 0);
1181
1284
  const obligation = this.getExecutionObligationCompliance(executionId);
1285
+ const failures = events.filter(event => Boolean(event.error) && !event.blocked);
1286
+ const blocked = events.filter(event => event.blocked);
1287
+ const directFallbacks = events.filter(event => event.mcpFallback);
1288
+ const hookFallbacks = execution?.sessionId
1289
+ ? this.listHookFallbacks({
1290
+ sessionId: execution.sessionId,
1291
+ from: execution.startedAt,
1292
+ to: finishedAt,
1293
+ })
1294
+ : [];
1295
+ const fallbackReasons = [...new Set([
1296
+ ...directFallbacks.map(() => 'direct_tool_during_context'),
1297
+ ...hookFallbacks.map(fallback => `daemon_${fallback.reason}`),
1298
+ ])];
1299
+ const eventReceiptIds = collectCanonicalReceiptIds(events.map(event => event.output));
1300
+ const suppliedMemoryReceiptIds = Array.isArray(metadata?.memoryReceiptIds)
1301
+ ? metadata.memoryReceiptIds.filter((id): id is string => typeof id === 'string')
1302
+ : [];
1303
+ const suppliedDistillReceiptIds = Array.isArray(metadata?.distillReceiptIds)
1304
+ ? metadata.distillReceiptIds.filter((id): id is string => typeof id === 'string')
1305
+ : [];
1306
+ const memoryReceiptIds = [...new Set([
1307
+ ...suppliedMemoryReceiptIds,
1308
+ ...eventReceiptIds.filter(id => id.startsWith('memory-receipt:')),
1309
+ ])];
1310
+ const distillReceiptIds = [...new Set([
1311
+ ...suppliedDistillReceiptIds,
1312
+ ...eventReceiptIds.filter(id => id.startsWith('distill-receipt:')),
1313
+ ])];
1182
1314
  this.updateSkillExecution(executionId, {
1183
1315
  status,
1184
1316
  finishedAt,
@@ -1190,7 +1322,23 @@ export class DevFlowDatabase {
1190
1322
  ? Math.max(0, finishedAt - execution.startedAt)
1191
1323
  : row?.first_at != null && row?.last_at != null ? row.last_at - row.first_at : 0,
1192
1324
  mcpComplianceRate: obligation.rate,
1193
- metadata,
1325
+ missedMcpTools: obligation.missedTools,
1326
+ failedToolCalls: failures.length,
1327
+ blockedToolCalls: blocked.length,
1328
+ fallbackCount: directFallbacks.length + hookFallbacks.length,
1329
+ fallbackReasons,
1330
+ metadata: {
1331
+ ...(metadata ?? {}),
1332
+ applicableObligations: obligation.applicable,
1333
+ satisfiedObligations: obligation.satisfied,
1334
+ missedTools: obligation.missedTools,
1335
+ actualFailureCount: failures.length,
1336
+ blockedCount: blocked.length,
1337
+ fallbackCount: directFallbacks.length + hookFallbacks.length,
1338
+ fallbackReasons,
1339
+ memoryReceiptIds,
1340
+ distillReceiptIds,
1341
+ },
1194
1342
  });
1195
1343
  }
1196
1344
 
@@ -1214,7 +1362,7 @@ export class DevFlowDatabase {
1214
1362
  if (obligation.endsWith(':domain')) {
1215
1363
  const family = obligation.slice(0, -':domain'.length);
1216
1364
  return events.some((event) => {
1217
- if (event.blocked || event.error || !event.isMcpTool) return false;
1365
+ if (!event.isMcpTool || !isEffectiveToolEvent(event)) return false;
1218
1366
  const name = String(event.mcpToolName ?? event.toolName ?? '')
1219
1367
  .replace(/^mcp__[^_]+__/, '');
1220
1368
  return name.startsWith(`${family}_`);
@@ -1853,6 +2001,183 @@ export class DevFlowDatabase {
1853
2001
  .run(now).changes;
1854
2002
  }
1855
2003
 
2004
+ beginMemoryTurn(input: Omit<MemoryTurnRecord, 'status' | 'memoryIds'>): MemoryTurnRecord {
2005
+ this.db.prepare(`
2006
+ INSERT OR IGNORE INTO devflow_memory_turns
2007
+ (turn_id, project_root, session_id, prompt_hash, event_id, status, memory_ids, created_at)
2008
+ VALUES (?, ?, ?, ?, ?, 'pending', '[]', ?)
2009
+ `).run(
2010
+ input.turnId,
2011
+ input.projectRoot,
2012
+ input.sessionId,
2013
+ input.promptHash,
2014
+ input.eventId,
2015
+ input.createdAt,
2016
+ );
2017
+ return this.getMemoryTurn(input.turnId)!;
2018
+ }
2019
+
2020
+ getMemoryTurn(turnId: string): MemoryTurnRecord | null {
2021
+ const row = this.db.prepare(
2022
+ 'SELECT * FROM devflow_memory_turns WHERE turn_id = ?',
2023
+ ).get(turnId) as any;
2024
+ return row ? this.mapMemoryTurn(row) : null;
2025
+ }
2026
+
2027
+ getPendingMemoryTurn(projectRoot: string, sessionId: string): MemoryTurnRecord | null {
2028
+ const row = this.db.prepare(`
2029
+ SELECT * FROM devflow_memory_turns
2030
+ WHERE project_root = ? AND session_id = ? AND status = 'pending'
2031
+ ORDER BY created_at DESC LIMIT 1
2032
+ `).get(projectRoot, sessionId) as any;
2033
+ return row ? this.mapMemoryTurn(row) : null;
2034
+ }
2035
+
2036
+ commitMemoryTurn(input: {
2037
+ turnId: string;
2038
+ receiptId: string;
2039
+ memoryIds: string[];
2040
+ source: string;
2041
+ reason?: string;
2042
+ decidedAt?: number;
2043
+ }): MemoryTurnRecord {
2044
+ this.db.prepare(`
2045
+ UPDATE devflow_memory_turns
2046
+ SET status = 'committed', receipt_id = ?, memory_ids = ?, source = ?, reason = ?, decided_at = ?
2047
+ WHERE turn_id = ? AND status = 'pending'
2048
+ `).run(
2049
+ input.receiptId,
2050
+ JSON.stringify([...new Set(input.memoryIds)]),
2051
+ input.source,
2052
+ input.reason ?? null,
2053
+ input.decidedAt ?? Date.now(),
2054
+ input.turnId,
2055
+ );
2056
+ const turn = this.getMemoryTurn(input.turnId);
2057
+ if (!turn || turn.status !== 'committed') {
2058
+ throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
2059
+ }
2060
+ return turn;
2061
+ }
2062
+
2063
+ skipMemoryTurn(input: {
2064
+ turnId: string;
2065
+ receiptId: string;
2066
+ reason: string;
2067
+ decidedAt?: number;
2068
+ }): MemoryTurnRecord {
2069
+ this.db.prepare(`
2070
+ UPDATE devflow_memory_turns
2071
+ SET status = 'skipped', receipt_id = ?, memory_ids = '[]', source = 'host_skip',
2072
+ reason = ?, decided_at = ?
2073
+ WHERE turn_id = ? AND status = 'pending'
2074
+ `).run(
2075
+ input.receiptId,
2076
+ input.reason,
2077
+ input.decidedAt ?? Date.now(),
2078
+ input.turnId,
2079
+ );
2080
+ const turn = this.getMemoryTurn(input.turnId);
2081
+ if (!turn || turn.status !== 'skipped') {
2082
+ throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
2083
+ }
2084
+ return turn;
2085
+ }
2086
+
2087
+ markMemoryTurnStopPrompted(turnId: string, promptedAt = Date.now()): boolean {
2088
+ return this.db.prepare(`
2089
+ UPDATE devflow_memory_turns SET stop_prompted_at = ?
2090
+ WHERE turn_id = ? AND status = 'pending' AND stop_prompted_at IS NULL
2091
+ `).run(promptedAt, turnId).changes === 1;
2092
+ }
2093
+
2094
+ listMemoryTurns(projectRoot: string, sessionId?: string, limit = 50): MemoryTurnRecord[] {
2095
+ const rows = (sessionId
2096
+ ? this.db.prepare(`SELECT * FROM devflow_memory_turns
2097
+ WHERE project_root = ? AND session_id = ? ORDER BY created_at DESC LIMIT ?`)
2098
+ .all(projectRoot, sessionId, limit)
2099
+ : this.db.prepare(`SELECT * FROM devflow_memory_turns
2100
+ WHERE project_root = ? ORDER BY created_at DESC LIMIT ?`)
2101
+ .all(projectRoot, limit)) as any[];
2102
+ return rows.map(row => this.mapMemoryTurn(row));
2103
+ }
2104
+
2105
+ insertHookFallback(record: HookFallbackRecord): boolean {
2106
+ return this.db.prepare(`
2107
+ INSERT OR IGNORE INTO devflow_hook_fallbacks
2108
+ (id, project_root, session_id, tool_use_id, request_type, tool, reason,
2109
+ duration_ms, attempts, created_at)
2110
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2111
+ `).run(
2112
+ record.id,
2113
+ record.projectRoot,
2114
+ record.sessionId ?? null,
2115
+ record.toolUseId ?? null,
2116
+ record.requestType,
2117
+ record.tool,
2118
+ record.reason,
2119
+ record.durationMs,
2120
+ record.attempts,
2121
+ record.createdAt,
2122
+ ).changes === 1;
2123
+ }
2124
+
2125
+ listHookFallbacks(filter: {
2126
+ projectRoot?: string;
2127
+ sessionId?: string;
2128
+ from?: number;
2129
+ to?: number;
2130
+ limit?: number;
2131
+ } = {}): HookFallbackRecord[] {
2132
+ const conditions: string[] = [];
2133
+ const values: unknown[] = [];
2134
+ if (filter.projectRoot) { conditions.push('project_root = ?'); values.push(filter.projectRoot); }
2135
+ if (filter.sessionId) { conditions.push('session_id = ?'); values.push(filter.sessionId); }
2136
+ if (filter.from !== undefined) { conditions.push('created_at >= ?'); values.push(filter.from); }
2137
+ if (filter.to !== undefined) { conditions.push('created_at <= ?'); values.push(filter.to); }
2138
+ values.push(Math.max(1, Math.min(filter.limit ?? 500, 5_000)));
2139
+ const rows = this.db.prepare(`
2140
+ SELECT * FROM devflow_hook_fallbacks
2141
+ ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
2142
+ ORDER BY created_at DESC LIMIT ?
2143
+ `).all(...values) as any[];
2144
+ return rows.map(row => ({
2145
+ id: row.id,
2146
+ projectRoot: row.project_root,
2147
+ sessionId: row.session_id ?? undefined,
2148
+ toolUseId: row.tool_use_id ?? undefined,
2149
+ requestType: row.request_type,
2150
+ tool: row.tool,
2151
+ reason: row.reason,
2152
+ durationMs: row.duration_ms,
2153
+ attempts: row.attempts,
2154
+ createdAt: row.created_at,
2155
+ }));
2156
+ }
2157
+
2158
+ private mapMemoryTurn(row: any): MemoryTurnRecord {
2159
+ let memoryIds: string[] = [];
2160
+ try {
2161
+ const parsed: unknown = JSON.parse(row.memory_ids ?? '[]');
2162
+ if (Array.isArray(parsed)) memoryIds = parsed.filter((id): id is string => typeof id === 'string');
2163
+ } catch {}
2164
+ return {
2165
+ turnId: row.turn_id,
2166
+ projectRoot: row.project_root,
2167
+ sessionId: row.session_id,
2168
+ promptHash: row.prompt_hash,
2169
+ eventId: row.event_id,
2170
+ status: row.status,
2171
+ receiptId: row.receipt_id ?? undefined,
2172
+ memoryIds,
2173
+ source: row.source ?? undefined,
2174
+ reason: row.reason ?? undefined,
2175
+ stopPromptedAt: row.stop_prompted_at ?? undefined,
2176
+ createdAt: row.created_at,
2177
+ decidedAt: row.decided_at ?? undefined,
2178
+ };
2179
+ }
2180
+
1856
2181
  recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void {
1857
2182
  this.db.prepare(`
1858
2183
  INSERT INTO devflow_memory_distill_checkpoints
@@ -1945,3 +2270,29 @@ function countTelemetryResults(value: unknown, depth = 0): number {
1945
2270
  if (record.error) return 0;
1946
2271
  return Object.keys(record).length > 0 ? 1 : 0;
1947
2272
  }
2273
+
2274
+ function collectCanonicalReceiptIds(values: unknown[]): string[] {
2275
+ const receiptIds = new Set<string>();
2276
+ const visit = (value: unknown, depth: number): void => {
2277
+ if (depth > 5 || value == null) return;
2278
+ if (typeof value === 'string') {
2279
+ if (value.startsWith('memory-receipt:') || value.startsWith('distill-receipt:')) {
2280
+ receiptIds.add(value);
2281
+ return;
2282
+ }
2283
+ if ((value.startsWith('{') || value.startsWith('[')) && value.length < 1_000_000) {
2284
+ try { visit(JSON.parse(value), depth + 1); } catch {}
2285
+ }
2286
+ return;
2287
+ }
2288
+ if (Array.isArray(value)) {
2289
+ value.forEach(item => visit(item, depth + 1));
2290
+ return;
2291
+ }
2292
+ if (typeof value === 'object') {
2293
+ Object.values(value as Record<string, unknown>).forEach(item => visit(item, depth + 1));
2294
+ }
2295
+ };
2296
+ values.forEach(value => visit(value, 0));
2297
+ return [...receiptIds];
2298
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,10 @@ export type {
9
9
  FeedbackRecord,
10
10
  GovernanceAuditRecord,
11
11
  GovernanceRuleRecord,
12
+ HookFallbackRecord,
12
13
  HookReceiptRecord,
13
14
  MemoryDistillCheckpointRecord,
15
+ MemoryTurnRecord,
16
+ MemoryTurnStatus,
14
17
  TelemetryFailureRecord,
15
18
  } from './database';