@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/dist/database.js CHANGED
@@ -8,6 +8,7 @@ const path_1 = require("path");
8
8
  const fs_1 = require("fs");
9
9
  const os_1 = require("os");
10
10
  const crypto_1 = require("crypto");
11
+ const obligation_ledger_1 = require("./obligation-ledger");
11
12
  const CONTEXT_REQUIRED_SKILLS = new Set([
12
13
  'react', 'vue', 'nest', 'nextjs', 'graphql', 'typescript',
13
14
  ]);
@@ -281,12 +282,32 @@ class DevFlowDatabase {
281
282
  context_hash TEXT NOT NULL,
282
283
  issued_at INTEGER NOT NULL,
283
284
  expires_at INTEGER NOT NULL,
285
+ selected_files TEXT NOT NULL DEFAULT '[]',
286
+ memory_ids TEXT NOT NULL DEFAULT '[]',
287
+ canonical_next_action TEXT,
288
+ request_id TEXT,
284
289
  PRIMARY KEY (project_root, session_id, execution_id)
285
290
  );
286
291
 
287
292
  CREATE INDEX IF NOT EXISTS idx_context_receipts_expiry
288
293
  ON devflow_context_receipts(expires_at);
289
294
 
295
+ CREATE TABLE IF NOT EXISTS devflow_context_selection_events (
296
+ id TEXT PRIMARY KEY,
297
+ project_root TEXT NOT NULL,
298
+ session_id TEXT NOT NULL,
299
+ execution_id TEXT NOT NULL,
300
+ request_id TEXT,
301
+ selection_type TEXT NOT NULL CHECK(selection_type IN ('code', 'action')),
302
+ candidate_id TEXT NOT NULL,
303
+ tool_name TEXT NOT NULL,
304
+ tool_use_id TEXT,
305
+ selected_at INTEGER NOT NULL
306
+ );
307
+
308
+ CREATE INDEX IF NOT EXISTS idx_context_selection_identity
309
+ ON devflow_context_selection_events(project_root, session_id, execution_id, request_id);
310
+
290
311
  CREATE TABLE IF NOT EXISTS devflow_memory_distill_checkpoints (
291
312
  id TEXT PRIMARY KEY,
292
313
  project_root TEXT NOT NULL,
@@ -294,6 +315,7 @@ class DevFlowDatabase {
294
315
  trigger TEXT NOT NULL CHECK(trigger IN ('pre_compact', 'session_end')),
295
316
  pending_events INTEGER NOT NULL DEFAULT 0,
296
317
  released_leases INTEGER NOT NULL DEFAULT 0,
318
+ details TEXT NOT NULL DEFAULT '{}',
297
319
  created_at INTEGER NOT NULL
298
320
  );
299
321
 
@@ -322,6 +344,32 @@ class DevFlowDatabase {
322
344
  CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_turns_receipt
323
345
  ON devflow_memory_turns(receipt_id) WHERE receipt_id IS NOT NULL;
324
346
 
347
+ CREATE TABLE IF NOT EXISTS devflow_session_obligations (
348
+ obligation_id TEXT NOT NULL,
349
+ project_root TEXT NOT NULL,
350
+ session_id TEXT NOT NULL,
351
+ execution_id TEXT,
352
+ turn_id TEXT,
353
+ kind TEXT NOT NULL
354
+ CHECK(kind IN ('memory_decision', 'evidence_contract', 'session_finalize')),
355
+ state TEXT NOT NULL DEFAULT 'open'
356
+ CHECK(state IN ('open', 'satisfied', 'degraded', 'cancelled')),
357
+ payload TEXT NOT NULL DEFAULT '{}',
358
+ receipt_id TEXT,
359
+ reason TEXT,
360
+ created_at INTEGER NOT NULL,
361
+ updated_at INTEGER NOT NULL,
362
+ resolved_at INTEGER,
363
+ PRIMARY KEY (project_root, session_id, obligation_id)
364
+ );
365
+
366
+ CREATE INDEX IF NOT EXISTS idx_session_obligations_state
367
+ ON devflow_session_obligations(project_root, session_id, state, created_at);
368
+ CREATE INDEX IF NOT EXISTS idx_session_obligations_turn
369
+ ON devflow_session_obligations(project_root, session_id, turn_id);
370
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_session_obligations_receipt
371
+ ON devflow_session_obligations(receipt_id) WHERE receipt_id IS NOT NULL;
372
+
325
373
  CREATE TABLE IF NOT EXISTS devflow_hook_fallbacks (
326
374
  id TEXT PRIMARY KEY,
327
375
  project_root TEXT NOT NULL,
@@ -424,6 +472,10 @@ class DevFlowDatabase {
424
472
  this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT');
425
473
  }
426
474
  catch { }
475
+ try {
476
+ this.db.exec("ALTER TABLE devflow_memory_distill_checkpoints ADD COLUMN details TEXT NOT NULL DEFAULT '{}'");
477
+ }
478
+ catch { }
427
479
  try {
428
480
  this.db.exec('ALTER TABLE skill_executions ADD COLUMN failed_tool_calls INTEGER DEFAULT 0');
429
481
  }
@@ -453,6 +505,22 @@ class DevFlowDatabase {
453
505
  this.db.exec('ALTER TABLE devflow_rules ADD COLUMN updated_at INTEGER');
454
506
  }
455
507
  catch { }
508
+ try {
509
+ this.db.exec("ALTER TABLE devflow_context_receipts ADD COLUMN selected_files TEXT NOT NULL DEFAULT '[]'");
510
+ }
511
+ catch { }
512
+ try {
513
+ this.db.exec("ALTER TABLE devflow_context_receipts ADD COLUMN memory_ids TEXT NOT NULL DEFAULT '[]'");
514
+ }
515
+ catch { }
516
+ try {
517
+ this.db.exec('ALTER TABLE devflow_context_receipts ADD COLUMN canonical_next_action TEXT');
518
+ }
519
+ catch { }
520
+ try {
521
+ this.db.exec('ALTER TABLE devflow_context_receipts ADD COLUMN request_id TEXT');
522
+ }
523
+ catch { }
456
524
  this.db.exec('UPDATE devflow_rules SET updated_at = created_at WHERE updated_at IS NULL');
457
525
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_devflow_rules_gate ON devflow_rules(gate, enabled)');
458
526
  // Migration: tool_metrics table for per-tool metrics collection
@@ -887,6 +955,15 @@ class DevFlowDatabase {
887
955
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
888
956
  };
889
957
  }
958
+ getLatestSkillExecutionForSession(sessionId) {
959
+ const row = this.db.prepare(`
960
+ SELECT execution_id FROM skill_executions
961
+ WHERE session_id = ?
962
+ ORDER BY started_at DESC, execution_id DESC
963
+ LIMIT 1
964
+ `).get(sessionId);
965
+ return row?.execution_id ? this.getSkillExecution(row.execution_id) : null;
966
+ }
890
967
  updateSkillExecution(executionId, updates) {
891
968
  const sets = [];
892
969
  const values = [];
@@ -951,6 +1028,17 @@ class DevFlowDatabase {
951
1028
  this.db.prepare(`UPDATE skill_executions SET ${sets.join(', ')} WHERE execution_id = ?`).run(...values);
952
1029
  }
953
1030
  }
1031
+ mergeSkillExecutionMetadata(executionId, incoming) {
1032
+ const execution = this.getSkillExecution(executionId);
1033
+ if (!execution)
1034
+ return null;
1035
+ const existing = execution.metadata && typeof execution.metadata === 'object'
1036
+ ? execution.metadata
1037
+ : {};
1038
+ const merged = mergeExecutionMetadata(existing, incoming);
1039
+ this.updateSkillExecution(executionId, { metadata: merged });
1040
+ return merged;
1041
+ }
954
1042
  listSkillExecutions(limit, skillName) {
955
1043
  let query = 'SELECT * FROM skill_executions';
956
1044
  const params = [];
@@ -1058,6 +1146,10 @@ class DevFlowDatabase {
1058
1146
  sets.push('error = ?');
1059
1147
  values.push(updates.error);
1060
1148
  }
1149
+ if (updates.failureCategory !== undefined) {
1150
+ sets.push('failure_category = ?');
1151
+ values.push(updates.failureCategory);
1152
+ }
1061
1153
  if (updates.duration !== undefined) {
1062
1154
  sets.push('duration = ?');
1063
1155
  values.push(updates.duration);
@@ -1095,23 +1187,38 @@ class DevFlowDatabase {
1095
1187
  const blocked = events.filter(event => event.blocked);
1096
1188
  const directFallbacks = events.filter(event => event.mcpFallback);
1097
1189
  const hookFallbacks = execution?.sessionId
1098
- ? this.listHookFallbacks({
1099
- sessionId: execution.sessionId,
1100
- from: execution.startedAt,
1101
- to: finishedAt,
1102
- })
1190
+ ? this.db.prepare(`
1191
+ SELECT * FROM devflow_hook_fallbacks
1192
+ WHERE session_id = ? AND created_at >= ? AND created_at <= ?
1193
+ ORDER BY created_at ASC
1194
+ `).all(execution.sessionId, execution.startedAt, finishedAt).map(row => ({
1195
+ id: row.id,
1196
+ projectRoot: row.project_root,
1197
+ sessionId: row.session_id ?? undefined,
1198
+ toolUseId: row.tool_use_id ?? undefined,
1199
+ requestType: row.request_type,
1200
+ tool: row.tool,
1201
+ reason: row.reason,
1202
+ durationMs: row.duration_ms,
1203
+ attempts: row.attempts,
1204
+ createdAt: row.created_at,
1205
+ }))
1103
1206
  : [];
1104
1207
  const fallbackReasons = [...new Set([
1105
1208
  ...directFallbacks.map(() => 'direct_tool_during_context'),
1106
1209
  ...hookFallbacks.map(fallback => `daemon_${fallback.reason}`),
1107
1210
  ])];
1108
1211
  const eventReceiptIds = collectCanonicalReceiptIds(events.map(event => event.output));
1109
- const suppliedMemoryReceiptIds = Array.isArray(metadata?.memoryReceiptIds)
1110
- ? metadata.memoryReceiptIds.filter((id) => typeof id === 'string')
1111
- : [];
1112
- const suppliedDistillReceiptIds = Array.isArray(metadata?.distillReceiptIds)
1113
- ? metadata.distillReceiptIds.filter((id) => typeof id === 'string')
1114
- : [];
1212
+ const existingMetadata = execution?.metadata && typeof execution.metadata === 'object'
1213
+ ? execution.metadata
1214
+ : {};
1215
+ const mergedMetadata = mergeExecutionMetadata(existingMetadata, metadata ?? {});
1216
+ const suppliedMemoryReceiptIds = [existingMetadata.memoryReceiptIds, metadata?.memoryReceiptIds]
1217
+ .flatMap(value => Array.isArray(value) ? value : [])
1218
+ .filter((id) => typeof id === 'string');
1219
+ const suppliedDistillReceiptIds = [existingMetadata.distillReceiptIds, metadata?.distillReceiptIds]
1220
+ .flatMap(value => Array.isArray(value) ? value : [])
1221
+ .filter((id) => typeof id === 'string');
1115
1222
  const memoryReceiptIds = [...new Set([
1116
1223
  ...suppliedMemoryReceiptIds,
1117
1224
  ...eventReceiptIds.filter(id => id.startsWith('memory-receipt:')),
@@ -1137,7 +1244,7 @@ class DevFlowDatabase {
1137
1244
  fallbackCount: directFallbacks.length + hookFallbacks.length,
1138
1245
  fallbackReasons,
1139
1246
  metadata: {
1140
- ...(metadata ?? {}),
1247
+ ...mergedMetadata,
1141
1248
  applicableObligations: obligation.applicable,
1142
1249
  satisfiedObligations: obligation.satisfied,
1143
1250
  missedTools: obligation.missedTools,
@@ -1708,14 +1815,7 @@ class DevFlowDatabase {
1708
1815
  return row ? this.mapSessionClosure(row) : null;
1709
1816
  }
1710
1817
  completeSessionClosure(projectRoot, sessionId, excludingWorkItemId, closedAt = Date.now()) {
1711
- const pending = this.db.prepare(`
1712
- SELECT COUNT(*) AS count
1713
- FROM devflow_work_items
1714
- WHERE project_root = ? AND session_id = ?
1715
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
1716
- AND (? IS NULL OR id <> ?)
1717
- `).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null);
1718
- const pendingWorkCount = Number(pending?.count ?? 0);
1818
+ const pendingWorkCount = this.countSessionLifecyclePending(projectRoot, sessionId, excludingWorkItemId);
1719
1819
  const state = pendingWorkCount > 0 ? 'closed_with_pending_work' : 'closed';
1720
1820
  this.db.prepare(`
1721
1821
  UPDATE devflow_session_closures
@@ -1823,6 +1923,16 @@ class DevFlowDatabase {
1823
1923
  WHERE id = ? AND state = 'leased' AND lease_owner = ?
1824
1924
  `).run(nextAttemptAt, error.category, error.message, updatedAt, id, owner).changes === 1;
1825
1925
  }
1926
+ deferWork(id, owner, error, nextAttemptAt) {
1927
+ const updatedAt = Date.now();
1928
+ return this.db.prepare(`
1929
+ UPDATE devflow_work_items
1930
+ SET state = 'pending', attempts = MAX(0, attempts - 1),
1931
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
1932
+ error_category = ?, error_message = ?, updated_at = ?
1933
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
1934
+ `).run(nextAttemptAt, error.category, error.message, updatedAt, id, owner).changes === 1;
1935
+ }
1826
1936
  deadLetterWork(id, owner, error) {
1827
1937
  return this.db.prepare(`
1828
1938
  UPDATE devflow_work_items
@@ -1909,13 +2019,7 @@ class DevFlowDatabase {
1909
2019
  };
1910
2020
  }
1911
2021
  refreshClosedSessionClosure(projectRoot, sessionId, now) {
1912
- const pending = this.db.prepare(`
1913
- SELECT COUNT(*) AS count
1914
- FROM devflow_work_items
1915
- WHERE project_root = ? AND session_id = ?
1916
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
1917
- `).get(projectRoot, sessionId);
1918
- const pendingWorkCount = Number(pending?.count ?? 0);
2022
+ const pendingWorkCount = this.countSessionLifecyclePending(projectRoot, sessionId);
1919
2023
  this.db.prepare(`
1920
2024
  UPDATE devflow_session_closures
1921
2025
  SET state = CASE WHEN ? = 0 THEN 'closed' ELSE 'closed_with_pending_work' END,
@@ -1924,6 +2028,34 @@ class DevFlowDatabase {
1924
2028
  AND state IN ('closed', 'closed_with_pending_work')
1925
2029
  `).run(pendingWorkCount, pendingWorkCount, now, projectRoot, sessionId);
1926
2030
  }
2031
+ countSessionLifecyclePending(projectRoot, sessionId, excludingWorkItemId) {
2032
+ const work = this.db.prepare(`
2033
+ SELECT COUNT(*) AS count
2034
+ FROM devflow_work_items
2035
+ WHERE project_root = ? AND session_id = ?
2036
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2037
+ AND (? IS NULL OR id <> ?)
2038
+ `).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null);
2039
+ const obligations = this.db.prepare(`
2040
+ SELECT COUNT(*) AS count
2041
+ FROM devflow_session_obligations
2042
+ WHERE project_root = ? AND session_id = ?
2043
+ AND state IN ('open', 'degraded')
2044
+ `).get(projectRoot, sessionId);
2045
+ const legacyTurns = this.db.prepare(`
2046
+ SELECT COUNT(*) AS count
2047
+ FROM devflow_memory_turns t
2048
+ WHERE t.project_root = ? AND t.session_id = ? AND t.status = 'pending'
2049
+ AND NOT EXISTS (
2050
+ SELECT 1 FROM devflow_session_obligations o
2051
+ WHERE o.project_root = t.project_root AND o.session_id = t.session_id
2052
+ AND o.obligation_id = 'memory:' || t.turn_id
2053
+ )
2054
+ `).get(projectRoot, sessionId);
2055
+ return Number(work?.count ?? 0)
2056
+ + Number(obligations?.count ?? 0)
2057
+ + Number(legacyTurns?.count ?? 0);
2058
+ }
1927
2059
  // ---- Hook Lifecycle ----
1928
2060
  getHookReceipt(projectRoot) {
1929
2061
  const row = this.db.prepare(`
@@ -1977,17 +2109,23 @@ class DevFlowDatabase {
1977
2109
  upsertContextReceipt(receipt) {
1978
2110
  this.db.prepare(`
1979
2111
  INSERT INTO devflow_context_receipts
1980
- (project_root, session_id, execution_id, context_hash, issued_at, expires_at)
1981
- VALUES (?, ?, ?, ?, ?, ?)
2112
+ (project_root, session_id, execution_id, context_hash, issued_at, expires_at,
2113
+ selected_files, memory_ids, canonical_next_action, request_id)
2114
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1982
2115
  ON CONFLICT(project_root, session_id, execution_id) DO UPDATE SET
1983
2116
  context_hash = excluded.context_hash,
1984
2117
  issued_at = excluded.issued_at,
1985
- expires_at = excluded.expires_at
1986
- `).run(receipt.projectRoot, receipt.sessionId, receipt.executionId, receipt.contextHash, receipt.issuedAt, receipt.expiresAt);
2118
+ expires_at = excluded.expires_at,
2119
+ selected_files = excluded.selected_files,
2120
+ memory_ids = excluded.memory_ids,
2121
+ canonical_next_action = excluded.canonical_next_action,
2122
+ request_id = excluded.request_id
2123
+ `).run(receipt.projectRoot, receipt.sessionId, receipt.executionId, receipt.contextHash, receipt.issuedAt, receipt.expiresAt, JSON.stringify(receipt.selectedFiles ?? []), JSON.stringify(receipt.memoryIds ?? []), receipt.canonicalNextAction ?? null, receipt.requestId ?? null);
1987
2124
  }
1988
2125
  getContextReceipt(projectRoot, sessionId, executionId) {
1989
2126
  const row = this.db.prepare(`
1990
- SELECT project_root, session_id, execution_id, context_hash, issued_at, expires_at
2127
+ SELECT project_root, session_id, execution_id, context_hash, issued_at, expires_at,
2128
+ selected_files, memory_ids, canonical_next_action, request_id
1991
2129
  FROM devflow_context_receipts
1992
2130
  WHERE project_root = ? AND session_id = ? AND execution_id = ?
1993
2131
  `).get(projectRoot, sessionId, executionId);
@@ -1998,8 +2136,77 @@ class DevFlowDatabase {
1998
2136
  contextHash: row.context_hash,
1999
2137
  issuedAt: row.issued_at,
2000
2138
  expiresAt: row.expires_at,
2139
+ selectedFiles: parseJsonStringArray(row.selected_files),
2140
+ memoryIds: parseJsonStringArray(row.memory_ids),
2141
+ canonicalNextAction: row.canonical_next_action ?? undefined,
2142
+ requestId: row.request_id ?? undefined,
2143
+ } : null;
2144
+ }
2145
+ getActiveContextReceipt(projectRoot, sessionId, executionId, now = Date.now()) {
2146
+ const executionClause = executionId ? 'AND execution_id = ?' : '';
2147
+ const params = executionId
2148
+ ? [projectRoot, sessionId, executionId, now]
2149
+ : [projectRoot, sessionId, now];
2150
+ const row = this.db.prepare(`
2151
+ SELECT project_root, session_id, execution_id, context_hash, issued_at, expires_at,
2152
+ selected_files, memory_ids, canonical_next_action, request_id
2153
+ FROM devflow_context_receipts
2154
+ WHERE project_root = ? AND session_id = ? ${executionClause} AND expires_at > ?
2155
+ ORDER BY issued_at DESC
2156
+ LIMIT 1
2157
+ `).get(...params);
2158
+ return row ? {
2159
+ projectRoot: row.project_root,
2160
+ sessionId: row.session_id,
2161
+ executionId: row.execution_id,
2162
+ contextHash: row.context_hash,
2163
+ issuedAt: row.issued_at,
2164
+ expiresAt: row.expires_at,
2165
+ selectedFiles: parseJsonStringArray(row.selected_files),
2166
+ memoryIds: parseJsonStringArray(row.memory_ids),
2167
+ canonicalNextAction: row.canonical_next_action ?? undefined,
2168
+ requestId: row.request_id ?? undefined,
2001
2169
  } : null;
2002
2170
  }
2171
+ recordContextSelectionEvent(event) {
2172
+ return this.db.prepare(`
2173
+ INSERT OR IGNORE INTO devflow_context_selection_events
2174
+ (id, project_root, session_id, execution_id, request_id, selection_type,
2175
+ candidate_id, tool_name, tool_use_id, selected_at)
2176
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2177
+ `).run(event.id, event.projectRoot, event.sessionId, event.executionId, event.requestId ?? null, event.selectionType, event.candidateId, event.toolName, event.toolUseId ?? null, event.selectedAt).changes > 0;
2178
+ }
2179
+ listContextSelectionEvents(options) {
2180
+ const predicates = ['project_root = ?'];
2181
+ const params = [options.projectRoot];
2182
+ for (const [column, value] of [
2183
+ ['session_id', options.sessionId],
2184
+ ['execution_id', options.executionId],
2185
+ ['request_id', options.requestId],
2186
+ ]) {
2187
+ if (!value)
2188
+ continue;
2189
+ predicates.push(`${column} = ?`);
2190
+ params.push(value);
2191
+ }
2192
+ const rows = this.db.prepare(`
2193
+ SELECT * FROM devflow_context_selection_events
2194
+ WHERE ${predicates.join(' AND ')}
2195
+ ORDER BY selected_at ASC, id ASC
2196
+ `).all(...params);
2197
+ return rows.map(row => ({
2198
+ id: row.id,
2199
+ projectRoot: row.project_root,
2200
+ sessionId: row.session_id,
2201
+ executionId: row.execution_id,
2202
+ requestId: row.request_id ?? undefined,
2203
+ selectionType: row.selection_type,
2204
+ candidateId: row.candidate_id,
2205
+ toolName: row.tool_name,
2206
+ toolUseId: row.tool_use_id ?? undefined,
2207
+ selectedAt: row.selected_at,
2208
+ }));
2209
+ }
2003
2210
  deleteContextReceipt(projectRoot, sessionId, executionId) {
2004
2211
  return executionId
2005
2212
  ? this.db.prepare(`DELETE FROM devflow_context_receipts
@@ -2014,15 +2221,104 @@ class DevFlowDatabase {
2014
2221
  .run(now).changes;
2015
2222
  }
2016
2223
  beginMemoryTurn(input) {
2224
+ const turnId = (0, obligation_ledger_1.normalizeTurnId)(input.turnId);
2017
2225
  this.db.prepare(`
2018
2226
  INSERT OR IGNORE INTO devflow_memory_turns
2019
2227
  (turn_id, project_root, session_id, prompt_hash, event_id, status, memory_ids, created_at)
2020
2228
  VALUES (?, ?, ?, ?, ?, 'pending', '[]', ?)
2021
- `).run(input.turnId, input.projectRoot, input.sessionId, input.promptHash, input.eventId, input.createdAt);
2022
- return this.getMemoryTurn(input.turnId);
2229
+ `).run(turnId, input.projectRoot, input.sessionId, input.promptHash, input.eventId, input.createdAt);
2230
+ const turn = this.getMemoryTurn(turnId);
2231
+ this.upsertSessionObligation({
2232
+ obligationId: `memory:${turnId}`,
2233
+ projectRoot: input.projectRoot,
2234
+ sessionId: input.sessionId,
2235
+ turnId,
2236
+ kind: 'memory_decision',
2237
+ state: turn.status === 'pending' ? 'open' : 'satisfied',
2238
+ payload: { eventId: input.eventId, promptHash: input.promptHash },
2239
+ receiptId: turn.receiptId,
2240
+ createdAt: input.createdAt,
2241
+ updatedAt: Date.now(),
2242
+ resolvedAt: turn.decidedAt,
2243
+ });
2244
+ return turn;
2245
+ }
2246
+ upsertSessionObligation(record) {
2247
+ if (!record.obligationId.trim())
2248
+ throw new Error('Session obligation requires an ID');
2249
+ if (!record.projectRoot.trim())
2250
+ throw new Error('Session obligation requires a project root');
2251
+ if (!record.sessionId.trim())
2252
+ throw new Error('Session obligation requires a session ID');
2253
+ const now = record.updatedAt || Date.now();
2254
+ this.db.prepare(`
2255
+ INSERT INTO devflow_session_obligations (
2256
+ obligation_id, project_root, session_id, execution_id, turn_id, kind,
2257
+ state, payload, receipt_id, reason, created_at, updated_at, resolved_at
2258
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2259
+ ON CONFLICT(project_root, session_id, obligation_id) DO UPDATE SET
2260
+ execution_id = COALESCE(excluded.execution_id, devflow_session_obligations.execution_id),
2261
+ turn_id = COALESCE(excluded.turn_id, devflow_session_obligations.turn_id),
2262
+ payload = excluded.payload,
2263
+ updated_at = excluded.updated_at
2264
+ WHERE devflow_session_obligations.state = 'open'
2265
+ `).run(record.obligationId, record.projectRoot, record.sessionId, record.executionId ?? null, record.turnId ?? null, record.kind, record.state, JSON.stringify(record.payload ?? {}), record.receiptId ?? null, record.reason ?? null, record.createdAt, now, record.resolvedAt ?? null);
2266
+ return this.getSessionObligation(record.projectRoot, record.sessionId, record.obligationId);
2267
+ }
2268
+ getSessionObligation(projectRoot, sessionId, obligationId) {
2269
+ const row = this.db.prepare(`
2270
+ SELECT * FROM devflow_session_obligations
2271
+ WHERE project_root = ? AND session_id = ? AND obligation_id = ?
2272
+ `).get(projectRoot, sessionId, obligationId);
2273
+ return row ? (0, obligation_ledger_1.mapSessionObligationRow)(row) : null;
2274
+ }
2275
+ listSessionObligations(projectRoot, sessionId, states) {
2276
+ const allowed = [...new Set(states ?? [])].filter(state => state === 'open' || state === 'satisfied' || state === 'degraded' || state === 'cancelled');
2277
+ const rows = (allowed.length > 0
2278
+ ? this.db.prepare(`
2279
+ SELECT * FROM devflow_session_obligations
2280
+ WHERE project_root = ? AND session_id = ?
2281
+ AND state IN (${allowed.map(() => '?').join(',')})
2282
+ ORDER BY created_at ASC, obligation_id ASC
2283
+ `).all(projectRoot, sessionId, ...allowed)
2284
+ : this.db.prepare(`
2285
+ SELECT * FROM devflow_session_obligations
2286
+ WHERE project_root = ? AND session_id = ?
2287
+ ORDER BY created_at ASC, obligation_id ASC
2288
+ `).all(projectRoot, sessionId));
2289
+ return rows.map(obligation_ledger_1.mapSessionObligationRow);
2290
+ }
2291
+ resolveSessionObligation(input) {
2292
+ return this.db.transaction(() => {
2293
+ const existing = this.getSessionObligation(input.projectRoot, input.sessionId, input.obligationId);
2294
+ if (!existing)
2295
+ throw new Error(`Session obligation ${input.obligationId} does not exist`);
2296
+ if (existing.state !== 'open') {
2297
+ const sameResolution = existing.state === input.state
2298
+ && (input.receiptId === undefined || existing.receiptId === input.receiptId);
2299
+ if (sameResolution)
2300
+ return existing;
2301
+ throw new Error(`OBLIGATION_TERMINAL_CONFLICT:${input.obligationId}`);
2302
+ }
2303
+ const resolvedAt = input.resolvedAt ?? Date.now();
2304
+ this.db.prepare(`
2305
+ UPDATE devflow_session_obligations
2306
+ SET state = ?, receipt_id = COALESCE(?, receipt_id), reason = ?,
2307
+ resolved_at = ?, updated_at = ?
2308
+ WHERE project_root = ? AND session_id = ? AND obligation_id = ? AND state = 'open'
2309
+ `).run(input.state, input.receiptId ?? null, input.reason ?? null, resolvedAt, resolvedAt, input.projectRoot, input.sessionId, input.obligationId);
2310
+ return this.getSessionObligation(input.projectRoot, input.sessionId, input.obligationId);
2311
+ });
2312
+ }
2313
+ countOpenSessionObligations(projectRoot, sessionId) {
2314
+ const row = this.db.prepare(`
2315
+ SELECT COUNT(*) AS count FROM devflow_session_obligations
2316
+ WHERE project_root = ? AND session_id = ? AND state = 'open'
2317
+ `).get(projectRoot, sessionId);
2318
+ return Number(row?.count ?? 0);
2023
2319
  }
2024
2320
  getMemoryTurn(turnId) {
2025
- const row = this.db.prepare('SELECT * FROM devflow_memory_turns WHERE turn_id = ?').get(turnId);
2321
+ const row = this.db.prepare('SELECT * FROM devflow_memory_turns WHERE turn_id = ?').get((0, obligation_ledger_1.normalizeTurnId)(turnId));
2026
2322
  return row ? this.mapMemoryTurn(row) : null;
2027
2323
  }
2028
2324
  getPendingMemoryTurn(projectRoot, sessionId) {
@@ -2034,35 +2330,73 @@ class DevFlowDatabase {
2034
2330
  return row ? this.mapMemoryTurn(row) : null;
2035
2331
  }
2036
2332
  commitMemoryTurn(input) {
2333
+ const turnId = (0, obligation_ledger_1.normalizeTurnId)(input.turnId);
2037
2334
  this.db.prepare(`
2038
2335
  UPDATE devflow_memory_turns
2039
2336
  SET status = 'committed', receipt_id = ?, memory_ids = ?, source = ?, reason = ?, decided_at = ?
2040
2337
  WHERE turn_id = ? AND status = 'pending'
2041
- `).run(input.receiptId, JSON.stringify([...new Set(input.memoryIds)]), input.source, input.reason ?? null, input.decidedAt ?? Date.now(), input.turnId);
2042
- const turn = this.getMemoryTurn(input.turnId);
2338
+ `).run(input.receiptId, JSON.stringify([...new Set(input.memoryIds)]), input.source, input.reason ?? null, input.decidedAt ?? Date.now(), turnId);
2339
+ const turn = this.getMemoryTurn(turnId);
2043
2340
  if (!turn || turn.status !== 'committed') {
2044
- throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
2045
- }
2341
+ throw new Error(`Memory turn ${turnId} is not pending or does not exist`);
2342
+ }
2343
+ this.ensureMemoryObligation(turn);
2344
+ this.resolveSessionObligation({
2345
+ projectRoot: turn.projectRoot,
2346
+ sessionId: turn.sessionId,
2347
+ obligationId: `memory:${turn.turnId}`,
2348
+ state: 'satisfied',
2349
+ receiptId: turn.receiptId,
2350
+ reason: turn.reason,
2351
+ resolvedAt: turn.decidedAt,
2352
+ });
2046
2353
  return turn;
2047
2354
  }
2048
2355
  skipMemoryTurn(input) {
2356
+ const turnId = (0, obligation_ledger_1.normalizeTurnId)(input.turnId);
2049
2357
  this.db.prepare(`
2050
2358
  UPDATE devflow_memory_turns
2051
2359
  SET status = 'skipped', receipt_id = ?, memory_ids = '[]', source = 'host_skip',
2052
2360
  reason = ?, decided_at = ?
2053
2361
  WHERE turn_id = ? AND status = 'pending'
2054
- `).run(input.receiptId, input.reason, input.decidedAt ?? Date.now(), input.turnId);
2055
- const turn = this.getMemoryTurn(input.turnId);
2362
+ `).run(input.receiptId, input.reason, input.decidedAt ?? Date.now(), turnId);
2363
+ const turn = this.getMemoryTurn(turnId);
2056
2364
  if (!turn || turn.status !== 'skipped') {
2057
- throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
2058
- }
2365
+ throw new Error(`Memory turn ${turnId} is not pending or does not exist`);
2366
+ }
2367
+ this.ensureMemoryObligation(turn);
2368
+ this.resolveSessionObligation({
2369
+ projectRoot: turn.projectRoot,
2370
+ sessionId: turn.sessionId,
2371
+ obligationId: `memory:${turn.turnId}`,
2372
+ state: 'satisfied',
2373
+ receiptId: turn.receiptId,
2374
+ reason: turn.reason,
2375
+ resolvedAt: turn.decidedAt,
2376
+ });
2059
2377
  return turn;
2060
2378
  }
2061
2379
  markMemoryTurnStopPrompted(turnId, promptedAt = Date.now()) {
2062
2380
  return this.db.prepare(`
2063
2381
  UPDATE devflow_memory_turns SET stop_prompted_at = ?
2064
2382
  WHERE turn_id = ? AND status = 'pending' AND stop_prompted_at IS NULL
2065
- `).run(promptedAt, turnId).changes === 1;
2383
+ `).run(promptedAt, (0, obligation_ledger_1.normalizeTurnId)(turnId)).changes === 1;
2384
+ }
2385
+ ensureMemoryObligation(turn) {
2386
+ const obligationId = `memory:${turn.turnId}`;
2387
+ if (this.getSessionObligation(turn.projectRoot, turn.sessionId, obligationId))
2388
+ return;
2389
+ this.upsertSessionObligation({
2390
+ obligationId,
2391
+ projectRoot: turn.projectRoot,
2392
+ sessionId: turn.sessionId,
2393
+ turnId: turn.turnId,
2394
+ kind: 'memory_decision',
2395
+ state: 'open',
2396
+ payload: { eventId: turn.eventId, promptHash: turn.promptHash },
2397
+ createdAt: turn.createdAt,
2398
+ updatedAt: Date.now(),
2399
+ });
2066
2400
  }
2067
2401
  listMemoryTurns(projectRoot, sessionId, limit = 50) {
2068
2402
  const rows = (sessionId
@@ -2074,6 +2408,14 @@ class DevFlowDatabase {
2074
2408
  .all(projectRoot, limit));
2075
2409
  return rows.map(row => this.mapMemoryTurn(row));
2076
2410
  }
2411
+ listSessionMemoryTurnsForReconciliation(projectRoot, sessionId) {
2412
+ const rows = this.db.prepare(`
2413
+ SELECT * FROM devflow_memory_turns
2414
+ WHERE project_root = ? AND session_id = ?
2415
+ ORDER BY created_at ASC, turn_id ASC
2416
+ `).all(projectRoot, sessionId);
2417
+ return rows.map(row => this.mapMemoryTurn(row));
2418
+ }
2077
2419
  insertHookFallback(record) {
2078
2420
  return this.db.prepare(`
2079
2421
  INSERT OR IGNORE INTO devflow_hook_fallbacks
@@ -2147,13 +2489,18 @@ class DevFlowDatabase {
2147
2489
  recordMemoryDistillCheckpoint(checkpoint) {
2148
2490
  this.db.prepare(`
2149
2491
  INSERT INTO devflow_memory_distill_checkpoints
2150
- (id, project_root, session_id, trigger, pending_events, released_leases, created_at)
2151
- VALUES (?, ?, ?, ?, ?, ?, ?)
2152
- `).run(checkpoint.id, checkpoint.projectRoot, checkpoint.sessionId ?? null, checkpoint.trigger, checkpoint.pendingEvents, checkpoint.releasedLeases, checkpoint.createdAt);
2492
+ (id, project_root, session_id, trigger, pending_events, released_leases, details, created_at)
2493
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2494
+ ON CONFLICT(id) DO UPDATE SET
2495
+ pending_events = excluded.pending_events,
2496
+ released_leases = MAX(devflow_memory_distill_checkpoints.released_leases, excluded.released_leases),
2497
+ details = excluded.details,
2498
+ created_at = MIN(devflow_memory_distill_checkpoints.created_at, excluded.created_at)
2499
+ `).run(checkpoint.id, checkpoint.projectRoot, checkpoint.sessionId ?? null, checkpoint.trigger, checkpoint.pendingEvents, checkpoint.releasedLeases, JSON.stringify(checkpoint.details ?? {}), checkpoint.createdAt);
2153
2500
  }
2154
2501
  listMemoryDistillCheckpoints(projectRoot, limit = 50) {
2155
2502
  const rows = this.db.prepare(`
2156
- SELECT id, project_root, session_id, trigger, pending_events, released_leases, created_at
2503
+ SELECT id, project_root, session_id, trigger, pending_events, released_leases, details, created_at
2157
2504
  FROM devflow_memory_distill_checkpoints
2158
2505
  WHERE project_root = ?
2159
2506
  ORDER BY created_at DESC
@@ -2166,6 +2513,7 @@ class DevFlowDatabase {
2166
2513
  trigger: row.trigger,
2167
2514
  pendingEvents: Number(row.pending_events),
2168
2515
  releasedLeases: Number(row.released_leases),
2516
+ details: row.details ? JSON.parse(row.details) : {},
2169
2517
  createdAt: Number(row.created_at),
2170
2518
  }));
2171
2519
  }
@@ -2191,6 +2539,12 @@ function parseJson(value) {
2191
2539
  return value;
2192
2540
  }
2193
2541
  }
2542
+ function parseJsonStringArray(value) {
2543
+ const parsed = parseJson(value);
2544
+ return Array.isArray(parsed)
2545
+ ? parsed.filter((item) => typeof item === 'string')
2546
+ : [];
2547
+ }
2194
2548
  function deriveQuery(input) {
2195
2549
  if (!input || typeof input !== 'object')
2196
2550
  return null;
@@ -2266,3 +2620,39 @@ function collectCanonicalReceiptIds(values) {
2266
2620
  values.forEach(value => visit(value, 0));
2267
2621
  return [...receiptIds];
2268
2622
  }
2623
+ function stableMetadataKey(value) {
2624
+ if (value === null || typeof value !== 'object')
2625
+ return JSON.stringify(value) ?? String(value);
2626
+ if (Array.isArray(value))
2627
+ return `[${value.map(stableMetadataKey).join(',')}]`;
2628
+ const record = value;
2629
+ return `{${Object.keys(record).sort()
2630
+ .map(key => `${JSON.stringify(key)}:${stableMetadataKey(record[key])}`)
2631
+ .join(',')}}`;
2632
+ }
2633
+ function unionMetadataArrays(existing, incoming) {
2634
+ const values = [
2635
+ ...(Array.isArray(existing) ? existing : []),
2636
+ ...(Array.isArray(incoming) ? incoming : []),
2637
+ ];
2638
+ if (values.length === 0 && !Array.isArray(existing) && !Array.isArray(incoming))
2639
+ return undefined;
2640
+ const unique = new Map();
2641
+ for (const value of values)
2642
+ unique.set(stableMetadataKey(value), value);
2643
+ return [...unique.values()];
2644
+ }
2645
+ function mergeExecutionMetadata(existing, incoming) {
2646
+ const merged = { ...existing, ...incoming };
2647
+ for (const key of [
2648
+ 'evidenceDegradations',
2649
+ 'evidenceContractOutcomes',
2650
+ 'pendingEvidenceContractIds',
2651
+ 'sessionFinalizationDegradations',
2652
+ ]) {
2653
+ const union = unionMetadataArrays(existing[key], incoming[key]);
2654
+ if (union !== undefined)
2655
+ merged[key] = union;
2656
+ }
2657
+ return merged;
2658
+ }