@devflow-tools/database 0.16.11 → 0.16.13

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
@@ -13,6 +13,12 @@ import type {
13
13
  WorkQueueHealth,
14
14
  WorkState,
15
15
  } from './work-queue';
16
+ import {
17
+ mapSessionObligationRow,
18
+ normalizeTurnId,
19
+ type SessionObligationRecord,
20
+ type SessionObligationState,
21
+ } from './obligation-ledger';
16
22
 
17
23
  export interface BenchmarkReportRecord {
18
24
  runId: string;
@@ -132,6 +138,23 @@ export interface ContextReceiptRecord {
132
138
  contextHash: string;
133
139
  issuedAt: number;
134
140
  expiresAt: number;
141
+ selectedFiles?: string[];
142
+ memoryIds?: string[];
143
+ canonicalNextAction?: string;
144
+ requestId?: string;
145
+ }
146
+
147
+ export interface ContextSelectionEventRecord {
148
+ id: string;
149
+ projectRoot: string;
150
+ sessionId: string;
151
+ executionId: string;
152
+ requestId?: string;
153
+ selectionType: 'code' | 'action';
154
+ candidateId: string;
155
+ toolName: string;
156
+ toolUseId?: string;
157
+ selectedAt: number;
135
158
  }
136
159
 
137
160
  export interface MemoryDistillCheckpointRecord {
@@ -141,6 +164,7 @@ export interface MemoryDistillCheckpointRecord {
141
164
  trigger: 'pre_compact' | 'session_end';
142
165
  pendingEvents: number;
143
166
  releasedLeases: number;
167
+ details?: Record<string, unknown>;
144
168
  createdAt: number;
145
169
  }
146
170
 
@@ -415,12 +439,32 @@ export class DevFlowDatabase {
415
439
  context_hash TEXT NOT NULL,
416
440
  issued_at INTEGER NOT NULL,
417
441
  expires_at INTEGER NOT NULL,
442
+ selected_files TEXT NOT NULL DEFAULT '[]',
443
+ memory_ids TEXT NOT NULL DEFAULT '[]',
444
+ canonical_next_action TEXT,
445
+ request_id TEXT,
418
446
  PRIMARY KEY (project_root, session_id, execution_id)
419
447
  );
420
448
 
421
449
  CREATE INDEX IF NOT EXISTS idx_context_receipts_expiry
422
450
  ON devflow_context_receipts(expires_at);
423
451
 
452
+ CREATE TABLE IF NOT EXISTS devflow_context_selection_events (
453
+ id TEXT PRIMARY KEY,
454
+ project_root TEXT NOT NULL,
455
+ session_id TEXT NOT NULL,
456
+ execution_id TEXT NOT NULL,
457
+ request_id TEXT,
458
+ selection_type TEXT NOT NULL CHECK(selection_type IN ('code', 'action')),
459
+ candidate_id TEXT NOT NULL,
460
+ tool_name TEXT NOT NULL,
461
+ tool_use_id TEXT,
462
+ selected_at INTEGER NOT NULL
463
+ );
464
+
465
+ CREATE INDEX IF NOT EXISTS idx_context_selection_identity
466
+ ON devflow_context_selection_events(project_root, session_id, execution_id, request_id);
467
+
424
468
  CREATE TABLE IF NOT EXISTS devflow_memory_distill_checkpoints (
425
469
  id TEXT PRIMARY KEY,
426
470
  project_root TEXT NOT NULL,
@@ -428,6 +472,7 @@ export class DevFlowDatabase {
428
472
  trigger TEXT NOT NULL CHECK(trigger IN ('pre_compact', 'session_end')),
429
473
  pending_events INTEGER NOT NULL DEFAULT 0,
430
474
  released_leases INTEGER NOT NULL DEFAULT 0,
475
+ details TEXT NOT NULL DEFAULT '{}',
431
476
  created_at INTEGER NOT NULL
432
477
  );
433
478
 
@@ -456,6 +501,32 @@ export class DevFlowDatabase {
456
501
  CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_turns_receipt
457
502
  ON devflow_memory_turns(receipt_id) WHERE receipt_id IS NOT NULL;
458
503
 
504
+ CREATE TABLE IF NOT EXISTS devflow_session_obligations (
505
+ obligation_id TEXT NOT NULL,
506
+ project_root TEXT NOT NULL,
507
+ session_id TEXT NOT NULL,
508
+ execution_id TEXT,
509
+ turn_id TEXT,
510
+ kind TEXT NOT NULL
511
+ CHECK(kind IN ('memory_decision', 'evidence_contract', 'session_finalize')),
512
+ state TEXT NOT NULL DEFAULT 'open'
513
+ CHECK(state IN ('open', 'satisfied', 'degraded', 'cancelled')),
514
+ payload TEXT NOT NULL DEFAULT '{}',
515
+ receipt_id TEXT,
516
+ reason TEXT,
517
+ created_at INTEGER NOT NULL,
518
+ updated_at INTEGER NOT NULL,
519
+ resolved_at INTEGER,
520
+ PRIMARY KEY (project_root, session_id, obligation_id)
521
+ );
522
+
523
+ CREATE INDEX IF NOT EXISTS idx_session_obligations_state
524
+ ON devflow_session_obligations(project_root, session_id, state, created_at);
525
+ CREATE INDEX IF NOT EXISTS idx_session_obligations_turn
526
+ ON devflow_session_obligations(project_root, session_id, turn_id);
527
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_session_obligations_receipt
528
+ ON devflow_session_obligations(receipt_id) WHERE receipt_id IS NOT NULL;
529
+
459
530
  CREATE TABLE IF NOT EXISTS devflow_hook_fallbacks (
460
531
  id TEXT PRIMARY KEY,
461
532
  project_root TEXT NOT NULL,
@@ -532,6 +603,7 @@ export class DevFlowDatabase {
532
603
  try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN tool_use_id TEXT'); } catch {}
533
604
  try { this.db.exec('ALTER TABLE sessions ADD COLUMN metadata TEXT'); } catch {}
534
605
  try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT'); } catch {}
606
+ try { this.db.exec("ALTER TABLE devflow_memory_distill_checkpoints ADD COLUMN details TEXT NOT NULL DEFAULT '{}'"); } catch {}
535
607
  try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN failed_tool_calls INTEGER DEFAULT 0'); } catch {}
536
608
  try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN blocked_tool_calls INTEGER DEFAULT 0'); } catch {}
537
609
  try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_count INTEGER DEFAULT 0'); } catch {}
@@ -542,6 +614,10 @@ export class DevFlowDatabase {
542
614
  } catch {}
543
615
  try { this.db.exec('ALTER TABLE devflow_rules ADD COLUMN gate INTEGER NOT NULL DEFAULT 1'); } catch {}
544
616
  try { this.db.exec('ALTER TABLE devflow_rules ADD COLUMN updated_at INTEGER'); } catch {}
617
+ try { this.db.exec("ALTER TABLE devflow_context_receipts ADD COLUMN selected_files TEXT NOT NULL DEFAULT '[]'"); } catch {}
618
+ try { this.db.exec("ALTER TABLE devflow_context_receipts ADD COLUMN memory_ids TEXT NOT NULL DEFAULT '[]'"); } catch {}
619
+ try { this.db.exec('ALTER TABLE devflow_context_receipts ADD COLUMN canonical_next_action TEXT'); } catch {}
620
+ try { this.db.exec('ALTER TABLE devflow_context_receipts ADD COLUMN request_id TEXT'); } catch {}
545
621
  this.db.exec('UPDATE devflow_rules SET updated_at = created_at WHERE updated_at IS NULL');
546
622
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_devflow_rules_gate ON devflow_rules(gate, enabled)');
547
623
 
@@ -1076,6 +1152,16 @@ export class DevFlowDatabase {
1076
1152
  };
1077
1153
  }
1078
1154
 
1155
+ getLatestSkillExecutionForSession(sessionId: string): any | null {
1156
+ const row = this.db.prepare(`
1157
+ SELECT execution_id FROM skill_executions
1158
+ WHERE session_id = ?
1159
+ ORDER BY started_at DESC, execution_id DESC
1160
+ LIMIT 1
1161
+ `).get(sessionId) as { execution_id?: string } | undefined;
1162
+ return row?.execution_id ? this.getSkillExecution(row.execution_id) : null;
1163
+ }
1164
+
1079
1165
  updateSkillExecution(executionId: string, updates: {
1080
1166
  status?: string;
1081
1167
  finishedAt?: number;
@@ -1116,6 +1202,20 @@ export class DevFlowDatabase {
1116
1202
  }
1117
1203
  }
1118
1204
 
1205
+ mergeSkillExecutionMetadata(
1206
+ executionId: string,
1207
+ incoming: Record<string, unknown>,
1208
+ ): Record<string, unknown> | null {
1209
+ const execution = this.getSkillExecution(executionId);
1210
+ if (!execution) return null;
1211
+ const existing = execution.metadata && typeof execution.metadata === 'object'
1212
+ ? execution.metadata as Record<string, unknown>
1213
+ : {};
1214
+ const merged = mergeExecutionMetadata(existing, incoming);
1215
+ this.updateSkillExecution(executionId, { metadata: merged });
1216
+ return merged;
1217
+ }
1218
+
1119
1219
  listSkillExecutions(limit: number, skillName?: string): any[] {
1120
1220
  let query = 'SELECT * FROM skill_executions';
1121
1221
  const params: any[] = [];
@@ -1284,7 +1384,12 @@ export class DevFlowDatabase {
1284
1384
  }));
1285
1385
  }
1286
1386
 
1287
- updateToolCallEvent(eventId: string, updates: { output?: string; error?: string; duration?: number }): boolean {
1387
+ updateToolCallEvent(eventId: string, updates: {
1388
+ output?: string;
1389
+ error?: string;
1390
+ failureCategory?: string;
1391
+ duration?: number;
1392
+ }): boolean {
1288
1393
  const sets: string[] = [];
1289
1394
  const values: any[] = [];
1290
1395
 
@@ -1296,6 +1401,10 @@ export class DevFlowDatabase {
1296
1401
  sets.push('error = ?');
1297
1402
  values.push(updates.error);
1298
1403
  }
1404
+ if (updates.failureCategory !== undefined) {
1405
+ sets.push('failure_category = ?');
1406
+ values.push(updates.failureCategory);
1407
+ }
1299
1408
  if (updates.duration !== undefined) {
1300
1409
  sets.push('duration = ?');
1301
1410
  values.push(updates.duration);
@@ -1343,23 +1452,38 @@ export class DevFlowDatabase {
1343
1452
  const blocked = events.filter(event => event.blocked);
1344
1453
  const directFallbacks = events.filter(event => event.mcpFallback);
1345
1454
  const hookFallbacks = execution?.sessionId
1346
- ? this.listHookFallbacks({
1347
- sessionId: execution.sessionId,
1348
- from: execution.startedAt,
1349
- to: finishedAt,
1350
- })
1455
+ ? (this.db.prepare(`
1456
+ SELECT * FROM devflow_hook_fallbacks
1457
+ WHERE session_id = ? AND created_at >= ? AND created_at <= ?
1458
+ ORDER BY created_at ASC
1459
+ `).all(execution.sessionId, execution.startedAt, finishedAt) as any[]).map(row => ({
1460
+ id: row.id,
1461
+ projectRoot: row.project_root,
1462
+ sessionId: row.session_id ?? undefined,
1463
+ toolUseId: row.tool_use_id ?? undefined,
1464
+ requestType: row.request_type,
1465
+ tool: row.tool,
1466
+ reason: row.reason,
1467
+ durationMs: row.duration_ms,
1468
+ attempts: row.attempts,
1469
+ createdAt: row.created_at,
1470
+ } satisfies HookFallbackRecord))
1351
1471
  : [];
1352
1472
  const fallbackReasons = [...new Set([
1353
1473
  ...directFallbacks.map(() => 'direct_tool_during_context'),
1354
1474
  ...hookFallbacks.map(fallback => `daemon_${fallback.reason}`),
1355
1475
  ])];
1356
1476
  const eventReceiptIds = collectCanonicalReceiptIds(events.map(event => event.output));
1357
- const suppliedMemoryReceiptIds = Array.isArray(metadata?.memoryReceiptIds)
1358
- ? metadata.memoryReceiptIds.filter((id): id is string => typeof id === 'string')
1359
- : [];
1360
- const suppliedDistillReceiptIds = Array.isArray(metadata?.distillReceiptIds)
1361
- ? metadata.distillReceiptIds.filter((id): id is string => typeof id === 'string')
1362
- : [];
1477
+ const existingMetadata = execution?.metadata && typeof execution.metadata === 'object'
1478
+ ? execution.metadata as Record<string, unknown>
1479
+ : {};
1480
+ const mergedMetadata = mergeExecutionMetadata(existingMetadata, metadata ?? {});
1481
+ const suppliedMemoryReceiptIds = [existingMetadata.memoryReceiptIds, metadata?.memoryReceiptIds]
1482
+ .flatMap(value => Array.isArray(value) ? value : [])
1483
+ .filter((id): id is string => typeof id === 'string');
1484
+ const suppliedDistillReceiptIds = [existingMetadata.distillReceiptIds, metadata?.distillReceiptIds]
1485
+ .flatMap(value => Array.isArray(value) ? value : [])
1486
+ .filter((id): id is string => typeof id === 'string');
1363
1487
  const memoryReceiptIds = [...new Set([
1364
1488
  ...suppliedMemoryReceiptIds,
1365
1489
  ...eventReceiptIds.filter(id => id.startsWith('memory-receipt:')),
@@ -1385,7 +1509,7 @@ export class DevFlowDatabase {
1385
1509
  fallbackCount: directFallbacks.length + hookFallbacks.length,
1386
1510
  fallbackReasons,
1387
1511
  metadata: {
1388
- ...(metadata ?? {}),
1512
+ ...mergedMetadata,
1389
1513
  applicableObligations: obligation.applicable,
1390
1514
  satisfiedObligations: obligation.satisfied,
1391
1515
  missedTools: obligation.missedTools,
@@ -2122,14 +2246,11 @@ export class DevFlowDatabase {
2122
2246
  excludingWorkItemId?: string,
2123
2247
  closedAt = Date.now(),
2124
2248
  ): SessionClosureRecord {
2125
- const pending = this.db.prepare(`
2126
- SELECT COUNT(*) AS count
2127
- FROM devflow_work_items
2128
- WHERE project_root = ? AND session_id = ?
2129
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2130
- AND (? IS NULL OR id <> ?)
2131
- `).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null) as { count?: number };
2132
- const pendingWorkCount = Number(pending?.count ?? 0);
2249
+ const pendingWorkCount = this.countSessionLifecyclePending(
2250
+ projectRoot,
2251
+ sessionId,
2252
+ excludingWorkItemId,
2253
+ );
2133
2254
  const state = pendingWorkCount > 0 ? 'closed_with_pending_work' : 'closed';
2134
2255
  this.db.prepare(`
2135
2256
  UPDATE devflow_session_closures
@@ -2256,6 +2377,24 @@ export class DevFlowDatabase {
2256
2377
  ).changes === 1;
2257
2378
  }
2258
2379
 
2380
+ deferWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean {
2381
+ const updatedAt = Date.now();
2382
+ return this.db.prepare(`
2383
+ UPDATE devflow_work_items
2384
+ SET state = 'pending', attempts = MAX(0, attempts - 1),
2385
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
2386
+ error_category = ?, error_message = ?, updated_at = ?
2387
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
2388
+ `).run(
2389
+ nextAttemptAt,
2390
+ error.category,
2391
+ error.message,
2392
+ updatedAt,
2393
+ id,
2394
+ owner,
2395
+ ).changes === 1;
2396
+ }
2397
+
2259
2398
  deadLetterWork(id: string, owner: string, error: WorkError): boolean {
2260
2399
  return this.db.prepare(`
2261
2400
  UPDATE devflow_work_items
@@ -2348,13 +2487,7 @@ export class DevFlowDatabase {
2348
2487
  }
2349
2488
 
2350
2489
  private refreshClosedSessionClosure(projectRoot: string, sessionId: string, now: number): void {
2351
- const pending = this.db.prepare(`
2352
- SELECT COUNT(*) AS count
2353
- FROM devflow_work_items
2354
- WHERE project_root = ? AND session_id = ?
2355
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2356
- `).get(projectRoot, sessionId) as { count?: number };
2357
- const pendingWorkCount = Number(pending?.count ?? 0);
2490
+ const pendingWorkCount = this.countSessionLifecyclePending(projectRoot, sessionId);
2358
2491
  this.db.prepare(`
2359
2492
  UPDATE devflow_session_closures
2360
2493
  SET state = CASE WHEN ? = 0 THEN 'closed' ELSE 'closed_with_pending_work' END,
@@ -2364,6 +2497,44 @@ export class DevFlowDatabase {
2364
2497
  `).run(pendingWorkCount, pendingWorkCount, now, projectRoot, sessionId);
2365
2498
  }
2366
2499
 
2500
+ private countSessionLifecyclePending(
2501
+ projectRoot: string,
2502
+ sessionId: string,
2503
+ excludingWorkItemId?: string,
2504
+ ): number {
2505
+ const work = this.db.prepare(`
2506
+ SELECT COUNT(*) AS count
2507
+ FROM devflow_work_items
2508
+ WHERE project_root = ? AND session_id = ?
2509
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2510
+ AND (? IS NULL OR id <> ?)
2511
+ `).get(
2512
+ projectRoot,
2513
+ sessionId,
2514
+ excludingWorkItemId ?? null,
2515
+ excludingWorkItemId ?? null,
2516
+ ) as { count?: number };
2517
+ const obligations = this.db.prepare(`
2518
+ SELECT COUNT(*) AS count
2519
+ FROM devflow_session_obligations
2520
+ WHERE project_root = ? AND session_id = ?
2521
+ AND state IN ('open', 'degraded')
2522
+ `).get(projectRoot, sessionId) as { count?: number };
2523
+ const legacyTurns = this.db.prepare(`
2524
+ SELECT COUNT(*) AS count
2525
+ FROM devflow_memory_turns t
2526
+ WHERE t.project_root = ? AND t.session_id = ? AND t.status = 'pending'
2527
+ AND NOT EXISTS (
2528
+ SELECT 1 FROM devflow_session_obligations o
2529
+ WHERE o.project_root = t.project_root AND o.session_id = t.session_id
2530
+ AND o.obligation_id = 'memory:' || t.turn_id
2531
+ )
2532
+ `).get(projectRoot, sessionId) as { count?: number };
2533
+ return Number(work?.count ?? 0)
2534
+ + Number(obligations?.count ?? 0)
2535
+ + Number(legacyTurns?.count ?? 0);
2536
+ }
2537
+
2367
2538
  // ---- Hook Lifecycle ----
2368
2539
 
2369
2540
  getHookReceipt(projectRoot: string): HookReceiptRecord | null {
@@ -2424,12 +2595,17 @@ export class DevFlowDatabase {
2424
2595
  upsertContextReceipt(receipt: ContextReceiptRecord): void {
2425
2596
  this.db.prepare(`
2426
2597
  INSERT INTO devflow_context_receipts
2427
- (project_root, session_id, execution_id, context_hash, issued_at, expires_at)
2428
- VALUES (?, ?, ?, ?, ?, ?)
2598
+ (project_root, session_id, execution_id, context_hash, issued_at, expires_at,
2599
+ selected_files, memory_ids, canonical_next_action, request_id)
2600
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2429
2601
  ON CONFLICT(project_root, session_id, execution_id) DO UPDATE SET
2430
2602
  context_hash = excluded.context_hash,
2431
2603
  issued_at = excluded.issued_at,
2432
- expires_at = excluded.expires_at
2604
+ expires_at = excluded.expires_at,
2605
+ selected_files = excluded.selected_files,
2606
+ memory_ids = excluded.memory_ids,
2607
+ canonical_next_action = excluded.canonical_next_action,
2608
+ request_id = excluded.request_id
2433
2609
  `).run(
2434
2610
  receipt.projectRoot,
2435
2611
  receipt.sessionId,
@@ -2437,6 +2613,10 @@ export class DevFlowDatabase {
2437
2613
  receipt.contextHash,
2438
2614
  receipt.issuedAt,
2439
2615
  receipt.expiresAt,
2616
+ JSON.stringify(receipt.selectedFiles ?? []),
2617
+ JSON.stringify(receipt.memoryIds ?? []),
2618
+ receipt.canonicalNextAction ?? null,
2619
+ receipt.requestId ?? null,
2440
2620
  );
2441
2621
  }
2442
2622
 
@@ -2446,7 +2626,8 @@ export class DevFlowDatabase {
2446
2626
  executionId: string,
2447
2627
  ): ContextReceiptRecord | null {
2448
2628
  const row = this.db.prepare(`
2449
- SELECT project_root, session_id, execution_id, context_hash, issued_at, expires_at
2629
+ SELECT project_root, session_id, execution_id, context_hash, issued_at, expires_at,
2630
+ selected_files, memory_ids, canonical_next_action, request_id
2450
2631
  FROM devflow_context_receipts
2451
2632
  WHERE project_root = ? AND session_id = ? AND execution_id = ?
2452
2633
  `).get(projectRoot, sessionId, executionId) as any;
@@ -2457,9 +2638,101 @@ export class DevFlowDatabase {
2457
2638
  contextHash: row.context_hash,
2458
2639
  issuedAt: row.issued_at,
2459
2640
  expiresAt: row.expires_at,
2641
+ selectedFiles: parseJsonStringArray(row.selected_files),
2642
+ memoryIds: parseJsonStringArray(row.memory_ids),
2643
+ canonicalNextAction: row.canonical_next_action ?? undefined,
2644
+ requestId: row.request_id ?? undefined,
2460
2645
  } : null;
2461
2646
  }
2462
2647
 
2648
+ getActiveContextReceipt(
2649
+ projectRoot: string,
2650
+ sessionId: string,
2651
+ executionId?: string,
2652
+ now = Date.now(),
2653
+ ): ContextReceiptRecord | null {
2654
+ const executionClause = executionId ? 'AND execution_id = ?' : '';
2655
+ const params = executionId
2656
+ ? [projectRoot, sessionId, executionId, now]
2657
+ : [projectRoot, sessionId, now];
2658
+ const row = this.db.prepare(`
2659
+ SELECT project_root, session_id, execution_id, context_hash, issued_at, expires_at,
2660
+ selected_files, memory_ids, canonical_next_action, request_id
2661
+ FROM devflow_context_receipts
2662
+ WHERE project_root = ? AND session_id = ? ${executionClause} AND expires_at > ?
2663
+ ORDER BY issued_at DESC
2664
+ LIMIT 1
2665
+ `).get(...params) as any;
2666
+ return row ? {
2667
+ projectRoot: row.project_root,
2668
+ sessionId: row.session_id,
2669
+ executionId: row.execution_id,
2670
+ contextHash: row.context_hash,
2671
+ issuedAt: row.issued_at,
2672
+ expiresAt: row.expires_at,
2673
+ selectedFiles: parseJsonStringArray(row.selected_files),
2674
+ memoryIds: parseJsonStringArray(row.memory_ids),
2675
+ canonicalNextAction: row.canonical_next_action ?? undefined,
2676
+ requestId: row.request_id ?? undefined,
2677
+ } : null;
2678
+ }
2679
+
2680
+ recordContextSelectionEvent(event: ContextSelectionEventRecord): boolean {
2681
+ return this.db.prepare(`
2682
+ INSERT OR IGNORE INTO devflow_context_selection_events
2683
+ (id, project_root, session_id, execution_id, request_id, selection_type,
2684
+ candidate_id, tool_name, tool_use_id, selected_at)
2685
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2686
+ `).run(
2687
+ event.id,
2688
+ event.projectRoot,
2689
+ event.sessionId,
2690
+ event.executionId,
2691
+ event.requestId ?? null,
2692
+ event.selectionType,
2693
+ event.candidateId,
2694
+ event.toolName,
2695
+ event.toolUseId ?? null,
2696
+ event.selectedAt,
2697
+ ).changes > 0;
2698
+ }
2699
+
2700
+ listContextSelectionEvents(options: {
2701
+ projectRoot: string;
2702
+ sessionId?: string;
2703
+ executionId?: string;
2704
+ requestId?: string;
2705
+ }): ContextSelectionEventRecord[] {
2706
+ const predicates = ['project_root = ?'];
2707
+ const params: unknown[] = [options.projectRoot];
2708
+ for (const [column, value] of [
2709
+ ['session_id', options.sessionId],
2710
+ ['execution_id', options.executionId],
2711
+ ['request_id', options.requestId],
2712
+ ] as const) {
2713
+ if (!value) continue;
2714
+ predicates.push(`${column} = ?`);
2715
+ params.push(value);
2716
+ }
2717
+ const rows = this.db.prepare(`
2718
+ SELECT * FROM devflow_context_selection_events
2719
+ WHERE ${predicates.join(' AND ')}
2720
+ ORDER BY selected_at ASC, id ASC
2721
+ `).all(...params) as any[];
2722
+ return rows.map(row => ({
2723
+ id: row.id,
2724
+ projectRoot: row.project_root,
2725
+ sessionId: row.session_id,
2726
+ executionId: row.execution_id,
2727
+ requestId: row.request_id ?? undefined,
2728
+ selectionType: row.selection_type,
2729
+ candidateId: row.candidate_id,
2730
+ toolName: row.tool_name,
2731
+ toolUseId: row.tool_use_id ?? undefined,
2732
+ selectedAt: row.selected_at,
2733
+ }));
2734
+ }
2735
+
2463
2736
  deleteContextReceipt(projectRoot: string, sessionId: string, executionId?: string): number {
2464
2737
  return executionId
2465
2738
  ? this.db.prepare(`DELETE FROM devflow_context_receipts
@@ -2476,25 +2749,158 @@ export class DevFlowDatabase {
2476
2749
  }
2477
2750
 
2478
2751
  beginMemoryTurn(input: Omit<MemoryTurnRecord, 'status' | 'memoryIds'>): MemoryTurnRecord {
2752
+ const turnId = normalizeTurnId(input.turnId);
2479
2753
  this.db.prepare(`
2480
2754
  INSERT OR IGNORE INTO devflow_memory_turns
2481
2755
  (turn_id, project_root, session_id, prompt_hash, event_id, status, memory_ids, created_at)
2482
2756
  VALUES (?, ?, ?, ?, ?, 'pending', '[]', ?)
2483
2757
  `).run(
2484
- input.turnId,
2758
+ turnId,
2485
2759
  input.projectRoot,
2486
2760
  input.sessionId,
2487
2761
  input.promptHash,
2488
2762
  input.eventId,
2489
2763
  input.createdAt,
2490
2764
  );
2491
- return this.getMemoryTurn(input.turnId)!;
2765
+ const turn = this.getMemoryTurn(turnId)!;
2766
+ this.upsertSessionObligation({
2767
+ obligationId: `memory:${turnId}`,
2768
+ projectRoot: input.projectRoot,
2769
+ sessionId: input.sessionId,
2770
+ turnId,
2771
+ kind: 'memory_decision',
2772
+ state: turn.status === 'pending' ? 'open' : 'satisfied',
2773
+ payload: { eventId: input.eventId, promptHash: input.promptHash },
2774
+ receiptId: turn.receiptId,
2775
+ createdAt: input.createdAt,
2776
+ updatedAt: Date.now(),
2777
+ resolvedAt: turn.decidedAt,
2778
+ });
2779
+ return turn;
2780
+ }
2781
+
2782
+ upsertSessionObligation(record: SessionObligationRecord): SessionObligationRecord {
2783
+ if (!record.obligationId.trim()) throw new Error('Session obligation requires an ID');
2784
+ if (!record.projectRoot.trim()) throw new Error('Session obligation requires a project root');
2785
+ if (!record.sessionId.trim()) throw new Error('Session obligation requires a session ID');
2786
+ const now = record.updatedAt || Date.now();
2787
+ this.db.prepare(`
2788
+ INSERT INTO devflow_session_obligations (
2789
+ obligation_id, project_root, session_id, execution_id, turn_id, kind,
2790
+ state, payload, receipt_id, reason, created_at, updated_at, resolved_at
2791
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2792
+ ON CONFLICT(project_root, session_id, obligation_id) DO UPDATE SET
2793
+ execution_id = COALESCE(excluded.execution_id, devflow_session_obligations.execution_id),
2794
+ turn_id = COALESCE(excluded.turn_id, devflow_session_obligations.turn_id),
2795
+ payload = excluded.payload,
2796
+ updated_at = excluded.updated_at
2797
+ WHERE devflow_session_obligations.state = 'open'
2798
+ `).run(
2799
+ record.obligationId,
2800
+ record.projectRoot,
2801
+ record.sessionId,
2802
+ record.executionId ?? null,
2803
+ record.turnId ?? null,
2804
+ record.kind,
2805
+ record.state,
2806
+ JSON.stringify(record.payload ?? {}),
2807
+ record.receiptId ?? null,
2808
+ record.reason ?? null,
2809
+ record.createdAt,
2810
+ now,
2811
+ record.resolvedAt ?? null,
2812
+ );
2813
+ return this.getSessionObligation(record.projectRoot, record.sessionId, record.obligationId)!;
2814
+ }
2815
+
2816
+ getSessionObligation(
2817
+ projectRoot: string,
2818
+ sessionId: string,
2819
+ obligationId: string,
2820
+ ): SessionObligationRecord | null {
2821
+ const row = this.db.prepare(`
2822
+ SELECT * FROM devflow_session_obligations
2823
+ WHERE project_root = ? AND session_id = ? AND obligation_id = ?
2824
+ `).get(projectRoot, sessionId, obligationId) as Record<string, unknown> | undefined;
2825
+ return row ? mapSessionObligationRow(row) : null;
2826
+ }
2827
+
2828
+ listSessionObligations(
2829
+ projectRoot: string,
2830
+ sessionId: string,
2831
+ states?: SessionObligationState[],
2832
+ ): SessionObligationRecord[] {
2833
+ const allowed = [...new Set(states ?? [])].filter(state =>
2834
+ state === 'open' || state === 'satisfied' || state === 'degraded' || state === 'cancelled');
2835
+ const rows = (allowed.length > 0
2836
+ ? this.db.prepare(`
2837
+ SELECT * FROM devflow_session_obligations
2838
+ WHERE project_root = ? AND session_id = ?
2839
+ AND state IN (${allowed.map(() => '?').join(',')})
2840
+ ORDER BY created_at ASC, obligation_id ASC
2841
+ `).all(projectRoot, sessionId, ...allowed)
2842
+ : this.db.prepare(`
2843
+ SELECT * FROM devflow_session_obligations
2844
+ WHERE project_root = ? AND session_id = ?
2845
+ ORDER BY created_at ASC, obligation_id ASC
2846
+ `).all(projectRoot, sessionId)) as Array<Record<string, unknown>>;
2847
+ return rows.map(mapSessionObligationRow);
2848
+ }
2849
+
2850
+ resolveSessionObligation(input: {
2851
+ projectRoot: string;
2852
+ sessionId: string;
2853
+ obligationId: string;
2854
+ state: 'satisfied' | 'degraded' | 'cancelled';
2855
+ receiptId?: string;
2856
+ reason?: string;
2857
+ resolvedAt?: number;
2858
+ }): SessionObligationRecord {
2859
+ return this.db.transaction(() => {
2860
+ const existing = this.getSessionObligation(
2861
+ input.projectRoot,
2862
+ input.sessionId,
2863
+ input.obligationId,
2864
+ );
2865
+ if (!existing) throw new Error(`Session obligation ${input.obligationId} does not exist`);
2866
+ if (existing.state !== 'open') {
2867
+ const sameResolution = existing.state === input.state
2868
+ && (input.receiptId === undefined || existing.receiptId === input.receiptId);
2869
+ if (sameResolution) return existing;
2870
+ throw new Error(`OBLIGATION_TERMINAL_CONFLICT:${input.obligationId}`);
2871
+ }
2872
+ const resolvedAt = input.resolvedAt ?? Date.now();
2873
+ this.db.prepare(`
2874
+ UPDATE devflow_session_obligations
2875
+ SET state = ?, receipt_id = COALESCE(?, receipt_id), reason = ?,
2876
+ resolved_at = ?, updated_at = ?
2877
+ WHERE project_root = ? AND session_id = ? AND obligation_id = ? AND state = 'open'
2878
+ `).run(
2879
+ input.state,
2880
+ input.receiptId ?? null,
2881
+ input.reason ?? null,
2882
+ resolvedAt,
2883
+ resolvedAt,
2884
+ input.projectRoot,
2885
+ input.sessionId,
2886
+ input.obligationId,
2887
+ );
2888
+ return this.getSessionObligation(input.projectRoot, input.sessionId, input.obligationId)!;
2889
+ });
2890
+ }
2891
+
2892
+ countOpenSessionObligations(projectRoot: string, sessionId: string): number {
2893
+ const row = this.db.prepare(`
2894
+ SELECT COUNT(*) AS count FROM devflow_session_obligations
2895
+ WHERE project_root = ? AND session_id = ? AND state = 'open'
2896
+ `).get(projectRoot, sessionId) as { count?: number };
2897
+ return Number(row?.count ?? 0);
2492
2898
  }
2493
2899
 
2494
2900
  getMemoryTurn(turnId: string): MemoryTurnRecord | null {
2495
2901
  const row = this.db.prepare(
2496
2902
  'SELECT * FROM devflow_memory_turns WHERE turn_id = ?',
2497
- ).get(turnId) as any;
2903
+ ).get(normalizeTurnId(turnId)) as any;
2498
2904
  return row ? this.mapMemoryTurn(row) : null;
2499
2905
  }
2500
2906
 
@@ -2515,6 +2921,7 @@ export class DevFlowDatabase {
2515
2921
  reason?: string;
2516
2922
  decidedAt?: number;
2517
2923
  }): MemoryTurnRecord {
2924
+ const turnId = normalizeTurnId(input.turnId);
2518
2925
  this.db.prepare(`
2519
2926
  UPDATE devflow_memory_turns
2520
2927
  SET status = 'committed', receipt_id = ?, memory_ids = ?, source = ?, reason = ?, decided_at = ?
@@ -2525,12 +2932,22 @@ export class DevFlowDatabase {
2525
2932
  input.source,
2526
2933
  input.reason ?? null,
2527
2934
  input.decidedAt ?? Date.now(),
2528
- input.turnId,
2935
+ turnId,
2529
2936
  );
2530
- const turn = this.getMemoryTurn(input.turnId);
2937
+ const turn = this.getMemoryTurn(turnId);
2531
2938
  if (!turn || turn.status !== 'committed') {
2532
- throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
2939
+ throw new Error(`Memory turn ${turnId} is not pending or does not exist`);
2533
2940
  }
2941
+ this.ensureMemoryObligation(turn);
2942
+ this.resolveSessionObligation({
2943
+ projectRoot: turn.projectRoot,
2944
+ sessionId: turn.sessionId,
2945
+ obligationId: `memory:${turn.turnId}`,
2946
+ state: 'satisfied',
2947
+ receiptId: turn.receiptId,
2948
+ reason: turn.reason,
2949
+ resolvedAt: turn.decidedAt,
2950
+ });
2534
2951
  return turn;
2535
2952
  }
2536
2953
 
@@ -2540,6 +2957,7 @@ export class DevFlowDatabase {
2540
2957
  reason: string;
2541
2958
  decidedAt?: number;
2542
2959
  }): MemoryTurnRecord {
2960
+ const turnId = normalizeTurnId(input.turnId);
2543
2961
  this.db.prepare(`
2544
2962
  UPDATE devflow_memory_turns
2545
2963
  SET status = 'skipped', receipt_id = ?, memory_ids = '[]', source = 'host_skip',
@@ -2549,12 +2967,22 @@ export class DevFlowDatabase {
2549
2967
  input.receiptId,
2550
2968
  input.reason,
2551
2969
  input.decidedAt ?? Date.now(),
2552
- input.turnId,
2970
+ turnId,
2553
2971
  );
2554
- const turn = this.getMemoryTurn(input.turnId);
2972
+ const turn = this.getMemoryTurn(turnId);
2555
2973
  if (!turn || turn.status !== 'skipped') {
2556
- throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
2974
+ throw new Error(`Memory turn ${turnId} is not pending or does not exist`);
2557
2975
  }
2976
+ this.ensureMemoryObligation(turn);
2977
+ this.resolveSessionObligation({
2978
+ projectRoot: turn.projectRoot,
2979
+ sessionId: turn.sessionId,
2980
+ obligationId: `memory:${turn.turnId}`,
2981
+ state: 'satisfied',
2982
+ receiptId: turn.receiptId,
2983
+ reason: turn.reason,
2984
+ resolvedAt: turn.decidedAt,
2985
+ });
2558
2986
  return turn;
2559
2987
  }
2560
2988
 
@@ -2562,7 +2990,23 @@ export class DevFlowDatabase {
2562
2990
  return this.db.prepare(`
2563
2991
  UPDATE devflow_memory_turns SET stop_prompted_at = ?
2564
2992
  WHERE turn_id = ? AND status = 'pending' AND stop_prompted_at IS NULL
2565
- `).run(promptedAt, turnId).changes === 1;
2993
+ `).run(promptedAt, normalizeTurnId(turnId)).changes === 1;
2994
+ }
2995
+
2996
+ private ensureMemoryObligation(turn: MemoryTurnRecord): void {
2997
+ const obligationId = `memory:${turn.turnId}`;
2998
+ if (this.getSessionObligation(turn.projectRoot, turn.sessionId, obligationId)) return;
2999
+ this.upsertSessionObligation({
3000
+ obligationId,
3001
+ projectRoot: turn.projectRoot,
3002
+ sessionId: turn.sessionId,
3003
+ turnId: turn.turnId,
3004
+ kind: 'memory_decision',
3005
+ state: 'open',
3006
+ payload: { eventId: turn.eventId, promptHash: turn.promptHash },
3007
+ createdAt: turn.createdAt,
3008
+ updatedAt: Date.now(),
3009
+ });
2566
3010
  }
2567
3011
 
2568
3012
  listMemoryTurns(projectRoot: string, sessionId?: string, limit = 50): MemoryTurnRecord[] {
@@ -2576,6 +3020,18 @@ export class DevFlowDatabase {
2576
3020
  return rows.map(row => this.mapMemoryTurn(row));
2577
3021
  }
2578
3022
 
3023
+ listSessionMemoryTurnsForReconciliation(
3024
+ projectRoot: string,
3025
+ sessionId: string,
3026
+ ): MemoryTurnRecord[] {
3027
+ const rows = this.db.prepare(`
3028
+ SELECT * FROM devflow_memory_turns
3029
+ WHERE project_root = ? AND session_id = ?
3030
+ ORDER BY created_at ASC, turn_id ASC
3031
+ `).all(projectRoot, sessionId) as any[];
3032
+ return rows.map(row => this.mapMemoryTurn(row));
3033
+ }
3034
+
2579
3035
  insertHookFallback(record: HookFallbackRecord): boolean {
2580
3036
  return this.db.prepare(`
2581
3037
  INSERT OR IGNORE INTO devflow_hook_fallbacks
@@ -2655,8 +3111,13 @@ export class DevFlowDatabase {
2655
3111
  recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void {
2656
3112
  this.db.prepare(`
2657
3113
  INSERT INTO devflow_memory_distill_checkpoints
2658
- (id, project_root, session_id, trigger, pending_events, released_leases, created_at)
2659
- VALUES (?, ?, ?, ?, ?, ?, ?)
3114
+ (id, project_root, session_id, trigger, pending_events, released_leases, details, created_at)
3115
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3116
+ ON CONFLICT(id) DO UPDATE SET
3117
+ pending_events = excluded.pending_events,
3118
+ released_leases = MAX(devflow_memory_distill_checkpoints.released_leases, excluded.released_leases),
3119
+ details = excluded.details,
3120
+ created_at = MIN(devflow_memory_distill_checkpoints.created_at, excluded.created_at)
2660
3121
  `).run(
2661
3122
  checkpoint.id,
2662
3123
  checkpoint.projectRoot,
@@ -2664,13 +3125,14 @@ export class DevFlowDatabase {
2664
3125
  checkpoint.trigger,
2665
3126
  checkpoint.pendingEvents,
2666
3127
  checkpoint.releasedLeases,
3128
+ JSON.stringify(checkpoint.details ?? {}),
2667
3129
  checkpoint.createdAt,
2668
3130
  );
2669
3131
  }
2670
3132
 
2671
3133
  listMemoryDistillCheckpoints(projectRoot: string, limit = 50): MemoryDistillCheckpointRecord[] {
2672
3134
  const rows = this.db.prepare(`
2673
- SELECT id, project_root, session_id, trigger, pending_events, released_leases, created_at
3135
+ SELECT id, project_root, session_id, trigger, pending_events, released_leases, details, created_at
2674
3136
  FROM devflow_memory_distill_checkpoints
2675
3137
  WHERE project_root = ?
2676
3138
  ORDER BY created_at DESC
@@ -2683,6 +3145,7 @@ export class DevFlowDatabase {
2683
3145
  trigger: row.trigger,
2684
3146
  pendingEvents: Number(row.pending_events),
2685
3147
  releasedLeases: Number(row.released_leases),
3148
+ details: row.details ? JSON.parse(row.details) : {},
2686
3149
  createdAt: Number(row.created_at),
2687
3150
  }));
2688
3151
  }
@@ -2706,6 +3169,13 @@ function parseJson(value: unknown): unknown {
2706
3169
  try { return JSON.parse(value); } catch { return value; }
2707
3170
  }
2708
3171
 
3172
+ function parseJsonStringArray(value: unknown): string[] {
3173
+ const parsed = parseJson(value);
3174
+ return Array.isArray(parsed)
3175
+ ? parsed.filter((item): item is string => typeof item === 'string')
3176
+ : [];
3177
+ }
3178
+
2709
3179
  function deriveQuery(input: unknown): string | null {
2710
3180
  if (!input || typeof input !== 'object') return null;
2711
3181
  const record = input as Record<string, unknown>;
@@ -2770,3 +3240,40 @@ function collectCanonicalReceiptIds(values: unknown[]): string[] {
2770
3240
  values.forEach(value => visit(value, 0));
2771
3241
  return [...receiptIds];
2772
3242
  }
3243
+
3244
+ function stableMetadataKey(value: unknown): string {
3245
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? String(value);
3246
+ if (Array.isArray(value)) return `[${value.map(stableMetadataKey).join(',')}]`;
3247
+ const record = value as Record<string, unknown>;
3248
+ return `{${Object.keys(record).sort()
3249
+ .map(key => `${JSON.stringify(key)}:${stableMetadataKey(record[key])}`)
3250
+ .join(',')}}`;
3251
+ }
3252
+
3253
+ function unionMetadataArrays(existing: unknown, incoming: unknown): unknown[] | undefined {
3254
+ const values = [
3255
+ ...(Array.isArray(existing) ? existing : []),
3256
+ ...(Array.isArray(incoming) ? incoming : []),
3257
+ ];
3258
+ if (values.length === 0 && !Array.isArray(existing) && !Array.isArray(incoming)) return undefined;
3259
+ const unique = new Map<string, unknown>();
3260
+ for (const value of values) unique.set(stableMetadataKey(value), value);
3261
+ return [...unique.values()];
3262
+ }
3263
+
3264
+ function mergeExecutionMetadata(
3265
+ existing: Record<string, unknown>,
3266
+ incoming: Record<string, unknown>,
3267
+ ): Record<string, unknown> {
3268
+ const merged = { ...existing, ...incoming };
3269
+ for (const key of [
3270
+ 'evidenceDegradations',
3271
+ 'evidenceContractOutcomes',
3272
+ 'pendingEvidenceContractIds',
3273
+ 'sessionFinalizationDegradations',
3274
+ ]) {
3275
+ const union = unionMetadataArrays(existing[key], incoming[key]);
3276
+ if (union !== undefined) merged[key] = union;
3277
+ }
3278
+ return merged;
3279
+ }