@devflow-tools/database 0.16.12 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devflow-tools/database",
3
- "version": "0.16.12",
3
+ "version": "0.16.13",
4
4
  "description": "DevFlow SQLite database package",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,5 +13,5 @@
13
13
  "typescript": "^5.5.0",
14
14
  "vitest": "^2.0.0"
15
15
  },
16
- "gitHead": "d4a018f3031a3570c794c253f8edeff405956133"
16
+ "gitHead": "67c9dfc3709bd0ce36b7c2a9341896eedcc8a725"
17
17
  }
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 {
@@ -416,12 +439,32 @@ export class DevFlowDatabase {
416
439
  context_hash TEXT NOT NULL,
417
440
  issued_at INTEGER NOT NULL,
418
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,
419
446
  PRIMARY KEY (project_root, session_id, execution_id)
420
447
  );
421
448
 
422
449
  CREATE INDEX IF NOT EXISTS idx_context_receipts_expiry
423
450
  ON devflow_context_receipts(expires_at);
424
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
+
425
468
  CREATE TABLE IF NOT EXISTS devflow_memory_distill_checkpoints (
426
469
  id TEXT PRIMARY KEY,
427
470
  project_root TEXT NOT NULL,
@@ -458,6 +501,32 @@ export class DevFlowDatabase {
458
501
  CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_turns_receipt
459
502
  ON devflow_memory_turns(receipt_id) WHERE receipt_id IS NOT NULL;
460
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
+
461
530
  CREATE TABLE IF NOT EXISTS devflow_hook_fallbacks (
462
531
  id TEXT PRIMARY KEY,
463
532
  project_root TEXT NOT NULL,
@@ -545,6 +614,10 @@ export class DevFlowDatabase {
545
614
  } catch {}
546
615
  try { this.db.exec('ALTER TABLE devflow_rules ADD COLUMN gate INTEGER NOT NULL DEFAULT 1'); } catch {}
547
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 {}
548
621
  this.db.exec('UPDATE devflow_rules SET updated_at = created_at WHERE updated_at IS NULL');
549
622
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_devflow_rules_gate ON devflow_rules(gate, enabled)');
550
623
 
@@ -1311,7 +1384,12 @@ export class DevFlowDatabase {
1311
1384
  }));
1312
1385
  }
1313
1386
 
1314
- 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 {
1315
1393
  const sets: string[] = [];
1316
1394
  const values: any[] = [];
1317
1395
 
@@ -1323,6 +1401,10 @@ export class DevFlowDatabase {
1323
1401
  sets.push('error = ?');
1324
1402
  values.push(updates.error);
1325
1403
  }
1404
+ if (updates.failureCategory !== undefined) {
1405
+ sets.push('failure_category = ?');
1406
+ values.push(updates.failureCategory);
1407
+ }
1326
1408
  if (updates.duration !== undefined) {
1327
1409
  sets.push('duration = ?');
1328
1410
  values.push(updates.duration);
@@ -2164,14 +2246,11 @@ export class DevFlowDatabase {
2164
2246
  excludingWorkItemId?: string,
2165
2247
  closedAt = Date.now(),
2166
2248
  ): SessionClosureRecord {
2167
- const pending = this.db.prepare(`
2168
- SELECT COUNT(*) AS count
2169
- FROM devflow_work_items
2170
- WHERE project_root = ? AND session_id = ?
2171
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2172
- AND (? IS NULL OR id <> ?)
2173
- `).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null) as { count?: number };
2174
- const pendingWorkCount = Number(pending?.count ?? 0);
2249
+ const pendingWorkCount = this.countSessionLifecyclePending(
2250
+ projectRoot,
2251
+ sessionId,
2252
+ excludingWorkItemId,
2253
+ );
2175
2254
  const state = pendingWorkCount > 0 ? 'closed_with_pending_work' : 'closed';
2176
2255
  this.db.prepare(`
2177
2256
  UPDATE devflow_session_closures
@@ -2408,13 +2487,7 @@ export class DevFlowDatabase {
2408
2487
  }
2409
2488
 
2410
2489
  private refreshClosedSessionClosure(projectRoot: string, sessionId: string, now: number): void {
2411
- const pending = this.db.prepare(`
2412
- SELECT COUNT(*) AS count
2413
- FROM devflow_work_items
2414
- WHERE project_root = ? AND session_id = ?
2415
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2416
- `).get(projectRoot, sessionId) as { count?: number };
2417
- const pendingWorkCount = Number(pending?.count ?? 0);
2490
+ const pendingWorkCount = this.countSessionLifecyclePending(projectRoot, sessionId);
2418
2491
  this.db.prepare(`
2419
2492
  UPDATE devflow_session_closures
2420
2493
  SET state = CASE WHEN ? = 0 THEN 'closed' ELSE 'closed_with_pending_work' END,
@@ -2424,6 +2497,44 @@ export class DevFlowDatabase {
2424
2497
  `).run(pendingWorkCount, pendingWorkCount, now, projectRoot, sessionId);
2425
2498
  }
2426
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
+
2427
2538
  // ---- Hook Lifecycle ----
2428
2539
 
2429
2540
  getHookReceipt(projectRoot: string): HookReceiptRecord | null {
@@ -2484,12 +2595,17 @@ export class DevFlowDatabase {
2484
2595
  upsertContextReceipt(receipt: ContextReceiptRecord): void {
2485
2596
  this.db.prepare(`
2486
2597
  INSERT INTO devflow_context_receipts
2487
- (project_root, session_id, execution_id, context_hash, issued_at, expires_at)
2488
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2489
2601
  ON CONFLICT(project_root, session_id, execution_id) DO UPDATE SET
2490
2602
  context_hash = excluded.context_hash,
2491
2603
  issued_at = excluded.issued_at,
2492
- 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
2493
2609
  `).run(
2494
2610
  receipt.projectRoot,
2495
2611
  receipt.sessionId,
@@ -2497,6 +2613,10 @@ export class DevFlowDatabase {
2497
2613
  receipt.contextHash,
2498
2614
  receipt.issuedAt,
2499
2615
  receipt.expiresAt,
2616
+ JSON.stringify(receipt.selectedFiles ?? []),
2617
+ JSON.stringify(receipt.memoryIds ?? []),
2618
+ receipt.canonicalNextAction ?? null,
2619
+ receipt.requestId ?? null,
2500
2620
  );
2501
2621
  }
2502
2622
 
@@ -2506,7 +2626,8 @@ export class DevFlowDatabase {
2506
2626
  executionId: string,
2507
2627
  ): ContextReceiptRecord | null {
2508
2628
  const row = this.db.prepare(`
2509
- 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
2510
2631
  FROM devflow_context_receipts
2511
2632
  WHERE project_root = ? AND session_id = ? AND execution_id = ?
2512
2633
  `).get(projectRoot, sessionId, executionId) as any;
@@ -2517,9 +2638,101 @@ export class DevFlowDatabase {
2517
2638
  contextHash: row.context_hash,
2518
2639
  issuedAt: row.issued_at,
2519
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,
2520
2645
  } : null;
2521
2646
  }
2522
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
+
2523
2736
  deleteContextReceipt(projectRoot: string, sessionId: string, executionId?: string): number {
2524
2737
  return executionId
2525
2738
  ? this.db.prepare(`DELETE FROM devflow_context_receipts
@@ -2536,25 +2749,158 @@ export class DevFlowDatabase {
2536
2749
  }
2537
2750
 
2538
2751
  beginMemoryTurn(input: Omit<MemoryTurnRecord, 'status' | 'memoryIds'>): MemoryTurnRecord {
2752
+ const turnId = normalizeTurnId(input.turnId);
2539
2753
  this.db.prepare(`
2540
2754
  INSERT OR IGNORE INTO devflow_memory_turns
2541
2755
  (turn_id, project_root, session_id, prompt_hash, event_id, status, memory_ids, created_at)
2542
2756
  VALUES (?, ?, ?, ?, ?, 'pending', '[]', ?)
2543
2757
  `).run(
2544
- input.turnId,
2758
+ turnId,
2545
2759
  input.projectRoot,
2546
2760
  input.sessionId,
2547
2761
  input.promptHash,
2548
2762
  input.eventId,
2549
2763
  input.createdAt,
2550
2764
  );
2551
- 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);
2552
2898
  }
2553
2899
 
2554
2900
  getMemoryTurn(turnId: string): MemoryTurnRecord | null {
2555
2901
  const row = this.db.prepare(
2556
2902
  'SELECT * FROM devflow_memory_turns WHERE turn_id = ?',
2557
- ).get(turnId) as any;
2903
+ ).get(normalizeTurnId(turnId)) as any;
2558
2904
  return row ? this.mapMemoryTurn(row) : null;
2559
2905
  }
2560
2906
 
@@ -2575,6 +2921,7 @@ export class DevFlowDatabase {
2575
2921
  reason?: string;
2576
2922
  decidedAt?: number;
2577
2923
  }): MemoryTurnRecord {
2924
+ const turnId = normalizeTurnId(input.turnId);
2578
2925
  this.db.prepare(`
2579
2926
  UPDATE devflow_memory_turns
2580
2927
  SET status = 'committed', receipt_id = ?, memory_ids = ?, source = ?, reason = ?, decided_at = ?
@@ -2585,12 +2932,22 @@ export class DevFlowDatabase {
2585
2932
  input.source,
2586
2933
  input.reason ?? null,
2587
2934
  input.decidedAt ?? Date.now(),
2588
- input.turnId,
2935
+ turnId,
2589
2936
  );
2590
- const turn = this.getMemoryTurn(input.turnId);
2937
+ const turn = this.getMemoryTurn(turnId);
2591
2938
  if (!turn || turn.status !== 'committed') {
2592
- 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`);
2593
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
+ });
2594
2951
  return turn;
2595
2952
  }
2596
2953
 
@@ -2600,6 +2957,7 @@ export class DevFlowDatabase {
2600
2957
  reason: string;
2601
2958
  decidedAt?: number;
2602
2959
  }): MemoryTurnRecord {
2960
+ const turnId = normalizeTurnId(input.turnId);
2603
2961
  this.db.prepare(`
2604
2962
  UPDATE devflow_memory_turns
2605
2963
  SET status = 'skipped', receipt_id = ?, memory_ids = '[]', source = 'host_skip',
@@ -2609,12 +2967,22 @@ export class DevFlowDatabase {
2609
2967
  input.receiptId,
2610
2968
  input.reason,
2611
2969
  input.decidedAt ?? Date.now(),
2612
- input.turnId,
2970
+ turnId,
2613
2971
  );
2614
- const turn = this.getMemoryTurn(input.turnId);
2972
+ const turn = this.getMemoryTurn(turnId);
2615
2973
  if (!turn || turn.status !== 'skipped') {
2616
- 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`);
2617
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
+ });
2618
2986
  return turn;
2619
2987
  }
2620
2988
 
@@ -2622,7 +2990,23 @@ export class DevFlowDatabase {
2622
2990
  return this.db.prepare(`
2623
2991
  UPDATE devflow_memory_turns SET stop_prompted_at = ?
2624
2992
  WHERE turn_id = ? AND status = 'pending' AND stop_prompted_at IS NULL
2625
- `).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
+ });
2626
3010
  }
2627
3011
 
2628
3012
  listMemoryTurns(projectRoot: string, sessionId?: string, limit = 50): MemoryTurnRecord[] {
@@ -2785,6 +3169,13 @@ function parseJson(value: unknown): unknown {
2785
3169
  try { return JSON.parse(value); } catch { return value; }
2786
3170
  }
2787
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
+
2788
3179
  function deriveQuery(input: unknown): string | null {
2789
3180
  if (!input || typeof input !== 'object') return null;
2790
3181
  const record = input as Record<string, unknown>;
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ export type {
11
11
  GovernanceRuleRecord,
12
12
  HookFallbackRecord,
13
13
  HookReceiptRecord,
14
+ ContextReceiptRecord,
15
+ ContextSelectionEventRecord,
14
16
  MemoryDistillCheckpointRecord,
15
17
  MemoryTurnRecord,
16
18
  MemoryTurnStatus,
@@ -28,3 +30,12 @@ export type {
28
30
  WorkQueueHealth,
29
31
  WorkState,
30
32
  } from './work-queue';
33
+ export {
34
+ mapSessionObligationRow,
35
+ normalizeTurnId,
36
+ } from './obligation-ledger';
37
+ export type {
38
+ SessionObligationKind,
39
+ SessionObligationRecord,
40
+ SessionObligationState,
41
+ } from './obligation-ledger';