claude_memory 0.1.0 → 0.2.0

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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/.mind.mv2.aLCUZd +0 -0
  3. data/.claude/memory.sqlite3 +0 -0
  4. data/.claude/rules/claude_memory.generated.md +7 -1
  5. data/.claude/settings.json +0 -4
  6. data/.claude/settings.local.json +4 -1
  7. data/.claude-plugin/plugin.json +1 -1
  8. data/.claude.json +11 -0
  9. data/.ruby-version +1 -0
  10. data/CHANGELOG.md +62 -11
  11. data/CLAUDE.md +87 -24
  12. data/README.md +76 -159
  13. data/docs/EXAMPLES.md +436 -0
  14. data/docs/RELEASE_NOTES_v0.2.0.md +179 -0
  15. data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +582 -0
  16. data/docs/SOCIAL_MEDIA_v0.2.0.md +420 -0
  17. data/docs/architecture.md +360 -0
  18. data/docs/expert_review.md +1718 -0
  19. data/docs/feature_adoption_plan.md +1241 -0
  20. data/docs/feature_adoption_plan_revised.md +2374 -0
  21. data/docs/improvements.md +1325 -0
  22. data/docs/quality_review.md +1544 -0
  23. data/docs/review_summary.md +480 -0
  24. data/lefthook.yml +10 -0
  25. data/lib/claude_memory/cli.rb +16 -844
  26. data/lib/claude_memory/commands/base_command.rb +95 -0
  27. data/lib/claude_memory/commands/changes_command.rb +39 -0
  28. data/lib/claude_memory/commands/conflicts_command.rb +37 -0
  29. data/lib/claude_memory/commands/db_init_command.rb +40 -0
  30. data/lib/claude_memory/commands/doctor_command.rb +147 -0
  31. data/lib/claude_memory/commands/explain_command.rb +65 -0
  32. data/lib/claude_memory/commands/help_command.rb +37 -0
  33. data/lib/claude_memory/commands/hook_command.rb +106 -0
  34. data/lib/claude_memory/commands/ingest_command.rb +47 -0
  35. data/lib/claude_memory/commands/init_command.rb +218 -0
  36. data/lib/claude_memory/commands/promote_command.rb +30 -0
  37. data/lib/claude_memory/commands/publish_command.rb +36 -0
  38. data/lib/claude_memory/commands/recall_command.rb +61 -0
  39. data/lib/claude_memory/commands/registry.rb +55 -0
  40. data/lib/claude_memory/commands/search_command.rb +43 -0
  41. data/lib/claude_memory/commands/serve_mcp_command.rb +16 -0
  42. data/lib/claude_memory/commands/sweep_command.rb +36 -0
  43. data/lib/claude_memory/commands/version_command.rb +13 -0
  44. data/lib/claude_memory/configuration.rb +38 -0
  45. data/lib/claude_memory/core/fact_id.rb +41 -0
  46. data/lib/claude_memory/core/null_explanation.rb +47 -0
  47. data/lib/claude_memory/core/null_fact.rb +30 -0
  48. data/lib/claude_memory/core/result.rb +143 -0
  49. data/lib/claude_memory/core/session_id.rb +37 -0
  50. data/lib/claude_memory/core/token_estimator.rb +33 -0
  51. data/lib/claude_memory/core/transcript_path.rb +37 -0
  52. data/lib/claude_memory/domain/conflict.rb +51 -0
  53. data/lib/claude_memory/domain/entity.rb +51 -0
  54. data/lib/claude_memory/domain/fact.rb +70 -0
  55. data/lib/claude_memory/domain/provenance.rb +48 -0
  56. data/lib/claude_memory/hook/exit_codes.rb +18 -0
  57. data/lib/claude_memory/hook/handler.rb +7 -2
  58. data/lib/claude_memory/index/index_query.rb +89 -0
  59. data/lib/claude_memory/index/index_query_logic.rb +41 -0
  60. data/lib/claude_memory/index/query_options.rb +67 -0
  61. data/lib/claude_memory/infrastructure/file_system.rb +29 -0
  62. data/lib/claude_memory/infrastructure/in_memory_file_system.rb +32 -0
  63. data/lib/claude_memory/ingest/content_sanitizer.rb +42 -0
  64. data/lib/claude_memory/ingest/ingester.rb +3 -0
  65. data/lib/claude_memory/ingest/privacy_tag.rb +48 -0
  66. data/lib/claude_memory/mcp/tools.rb +174 -1
  67. data/lib/claude_memory/publish.rb +29 -20
  68. data/lib/claude_memory/recall.rb +164 -16
  69. data/lib/claude_memory/resolve/resolver.rb +41 -37
  70. data/lib/claude_memory/shortcuts.rb +56 -0
  71. data/lib/claude_memory/store/store_manager.rb +35 -32
  72. data/lib/claude_memory/templates/hooks.example.json +0 -4
  73. data/lib/claude_memory/version.rb +1 -1
  74. data/lib/claude_memory.rb +59 -21
  75. metadata +55 -1
@@ -0,0 +1,1544 @@
1
+ # Code Quality Review - Ruby Best Practices
2
+
3
+ **Reviewed by perspectives of:** Sandi Metz, Jeremy Evans, Kent Beck, Avdi Grimm, Gary Bernhardt
4
+
5
+ **Review Date:** 2026-01-21
6
+
7
+ ---
8
+
9
+ ## Executive Summary
10
+
11
+ This codebase demonstrates good fundamentals with frozen string literals, consistent use of Sequel, and reasonable test coverage. However, there are significant opportunities for improvement in object-oriented design, separation of concerns, and adherence to Ruby idioms. The most critical issues center around:
12
+
13
+ 1. **CLI God Object** - 867-line class with too many responsibilities
14
+ 2. **Mixed Concerns** - I/O interleaved with business logic throughout
15
+ 3. **Inconsistent Database Practices** - Mix of Sequel datasets and raw SQL
16
+ 4. **Lack of Domain Objects** - Primitive obsession with hashes
17
+ 5. **State Management** - Mutable instance variables where immutability preferred
18
+
19
+ ---
20
+
21
+ ## 1. Sandi Metz Perspective (POODR)
22
+
23
+ ### Focus Areas
24
+ - Single Responsibility Principle
25
+ - Small, focused methods
26
+ - Clear dependencies
27
+ - DRY principle
28
+ - High test coverage
29
+
30
+ ### Critical Issues
31
+
32
+ #### 🔴 CLI God Object (cli.rb:1-867)
33
+
34
+ **Problem:** The CLI class has 867 lines and handles parsing, validation, execution, database management, configuration, output formatting, and error handling.
35
+
36
+ **Violations:**
37
+ - Single Responsibility Principle violated
38
+ - Too many public methods (18+ commands)
39
+ - Too many private methods (20+)
40
+ - Methods > 10 lines (doctor_cmd, init_local, configure_global_hooks, etc.)
41
+
42
+ **Example:**
43
+ ```ruby
44
+ # cli.rb:689-743 - doctor_cmd does too much
45
+ def doctor_cmd
46
+ issues = []
47
+ warnings = []
48
+
49
+ # Database checking
50
+ # File system checking
51
+ # Config validation
52
+ # Conflict detection
53
+ # Output formatting
54
+ # Error handling
55
+ end
56
+ ```
57
+
58
+ **Recommended Fix:**
59
+ Extract command objects:
60
+ ```ruby
61
+ # lib/claude_memory/commands/doctor.rb
62
+ module ClaudeMemory
63
+ module Commands
64
+ class Doctor
65
+ def initialize(store_manager, reporter:)
66
+ @store_manager = store_manager
67
+ @reporter = reporter
68
+ end
69
+
70
+ def call
71
+ checks = [
72
+ DatabaseCheck.new(@store_manager),
73
+ SnapshotCheck.new,
74
+ HooksCheck.new
75
+ ]
76
+
77
+ results = checks.map(&:call)
78
+ @reporter.report(results)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ ```
84
+
85
+ #### 🔴 Long Methods Throughout
86
+
87
+ **Problem:** Many methods exceed 10-15 lines, making them hard to understand and test.
88
+
89
+ **Examples:**
90
+ - `cli.rb:689-743` - `doctor_cmd` (55 lines)
91
+ - `cli.rb:536-565` - `init_local` (30 lines)
92
+ - `cli.rb:586-601` - `configure_global_hooks` (16 lines)
93
+ - `recall.rb:58-78` - `query_dual` (21 lines)
94
+
95
+ **Recommended Fix:**
96
+ Break into smaller, well-named private methods:
97
+ ```ruby
98
+ def doctor_cmd
99
+ results = run_health_checks
100
+ display_results(results)
101
+ exit_code_from(results)
102
+ end
103
+
104
+ private
105
+
106
+ def run_health_checks
107
+ [
108
+ check_global_database,
109
+ check_project_database,
110
+ check_snapshot,
111
+ check_hooks
112
+ ]
113
+ end
114
+ ```
115
+
116
+ #### 🟡 Duplicated Attribute Readers (store_manager.rb:47-49)
117
+
118
+ **Problem:**
119
+ ```ruby
120
+ attr_reader :global_store, :project_store, :project_path # line 8
121
+
122
+ # ... later ...
123
+
124
+ attr_reader :global_db_path # line 47
125
+ attr_reader :project_db_path # line 49
126
+ ```
127
+
128
+ **Fix:** Consolidate at the top of the class.
129
+
130
+ #### 🟡 Multiple Responsibilities in Recall Class
131
+
132
+ **Problem:** Recall handles both legacy single-store mode and dual-database mode (recall.rb:9-20).
133
+
134
+ **Violations:**
135
+ - Two modes = two responsibilities
136
+ - Conditional logic based on mode throughout
137
+ - Hard to reason about which path executes
138
+
139
+ **Recommended Fix:**
140
+ Create separate classes:
141
+ ```ruby
142
+ class LegacyRecall
143
+ # Single store logic only
144
+ end
145
+
146
+ class DualRecall
147
+ # Dual store logic only
148
+ end
149
+
150
+ # Factory
151
+ def self.build(store_or_manager)
152
+ if store_or_manager.is_a?(Store::StoreManager)
153
+ DualRecall.new(store_or_manager)
154
+ else
155
+ LegacyRecall.new(store_or_manager)
156
+ end
157
+ end
158
+ ```
159
+
160
+ #### 🟡 Inconsistent Visibility (sqlite_store.rb:204)
161
+
162
+ **Problem:**
163
+ ```ruby
164
+ private # line 59
165
+
166
+ # ... private methods ...
167
+
168
+ public # line 204
169
+
170
+ def upsert_content_item(...)
171
+ ```
172
+
173
+ **Recommended:** Keep all public methods together at the top, all private at the bottom.
174
+
175
+ ---
176
+
177
+ ## 2. Jeremy Evans Perspective (Sequel Expert)
178
+
179
+ ### Focus Areas
180
+ - Proper Sequel usage patterns
181
+ - Database performance
182
+ - Schema design
183
+ - Connection management
184
+
185
+ ### Critical Issues
186
+
187
+ #### 🔴 Raw SQL Instead of Sequel Datasets (cli.rb:752-764)
188
+
189
+ **Problem:**
190
+ ```ruby
191
+ fact_count = store.db.execute("SELECT COUNT(*) FROM facts").first.first
192
+ content_count = store.db.execute("SELECT COUNT(*) FROM content_items").first.first
193
+ conflict_count = store.db.execute("SELECT COUNT(*) FROM conflicts WHERE status = 'open'").first.first
194
+ last_ingest = store.db.execute("SELECT MAX(ingested_at) FROM content_items").first.first
195
+ ```
196
+
197
+ **Violations:**
198
+ - Bypasses Sequel's dataset API
199
+ - Inconsistent with rest of codebase
200
+ - No type casting or safety checks
201
+ - Raw SQL is harder to test
202
+
203
+ **Recommended Fix:**
204
+ ```ruby
205
+ fact_count = store.facts.count
206
+ content_count = store.content_items.count
207
+ conflict_count = store.conflicts.where(status: 'open').count
208
+ last_ingest = store.content_items.max(:ingested_at)
209
+ ```
210
+
211
+ #### 🔴 No Transaction Wrapping (store_manager.rb:79-122)
212
+
213
+ **Problem:** `promote_fact` performs multiple database writes without transaction:
214
+ ```ruby
215
+ def promote_fact(fact_id)
216
+ ensure_both!
217
+
218
+ fact = @project_store.facts.where(id: fact_id).first
219
+ # ... multiple inserts across two databases
220
+ global_fact_id = @global_store.insert_fact(...)
221
+ copy_provenance(fact_id, global_fact_id)
222
+
223
+ global_fact_id
224
+ end
225
+ ```
226
+
227
+ **Risk:** If `copy_provenance` fails, you have orphaned fact in global database.
228
+
229
+ **Recommended Fix:**
230
+ ```ruby
231
+ def promote_fact(fact_id)
232
+ ensure_both!
233
+
234
+ @global_store.db.transaction do
235
+ fact = @project_store.facts.where(id: fact_id).first
236
+ return nil unless fact
237
+
238
+ # ... inserts ...
239
+ end
240
+ end
241
+ ```
242
+
243
+ **Note:** Cross-database transactions are not atomic, but at least wrap single-DB operations.
244
+
245
+ #### 🔴 String Timestamps Instead of Time Objects
246
+
247
+ **Problem:** Throughout the codebase:
248
+ ```ruby
249
+ String :created_at, null: false # sqlite_store.rb:127
250
+ now = Time.now.utc.iso8601 # sqlite_store.rb:211
251
+ ```
252
+
253
+ **Issues:**
254
+ - String comparison for dates is fragile
255
+ - No timezone enforcement at DB level
256
+ - Manual ISO8601 conversion everywhere
257
+ - Harder to query by date ranges
258
+
259
+ **Recommended Fix:**
260
+ ```ruby
261
+ # Use DateTime columns
262
+ DateTime :created_at, null: false
263
+
264
+ # Use Sequel's timestamp plugin
265
+ Sequel.extension :date_arithmetic
266
+ plugin :timestamps, update_on_create: true
267
+ ```
268
+
269
+ #### 🟡 No Connection Pooling Configuration
270
+
271
+ **Problem:** SQLite connections created without pooling options (sqlite_store.rb:15):
272
+ ```ruby
273
+ @db = Sequel.sqlite(db_path)
274
+ ```
275
+
276
+ **Recommendation:**
277
+ ```ruby
278
+ @db = Sequel.connect(
279
+ adapter: 'sqlite',
280
+ database: db_path,
281
+ max_connections: 4,
282
+ pool_timeout: 5
283
+ )
284
+ ```
285
+
286
+ #### 🟡 Manual Schema Migrations (sqlite_store.rb:68-91)
287
+
288
+ **Problem:** Hand-rolled migration system instead of Sequel's migration framework.
289
+
290
+ **Issues:**
291
+ - No rollback support
292
+ - No migration history
293
+ - Schema changes mixed with initialization
294
+
295
+ **Recommended:**
296
+ Use Sequel's migration extension:
297
+ ```ruby
298
+ # db/migrations/001_initial_schema.rb
299
+ Sequel.migration do
300
+ up do
301
+ create_table(:entities) do
302
+ primary_key :id
303
+ String :type, null: false
304
+ # ...
305
+ end
306
+ end
307
+
308
+ down do
309
+ drop_table(:entities)
310
+ end
311
+ end
312
+
313
+ # In code:
314
+ Sequel::Migrator.run(@db, 'db/migrations')
315
+ ```
316
+
317
+ #### 🟡 Sequel Plugins Not Used
318
+
319
+ **Problem:** No use of helpful Sequel plugins:
320
+ - `timestamps` - automatic created_at/updated_at
321
+ - `validation_helpers` - model validations
322
+ - `json_serializer` - better JSON handling
323
+ - `association_dependencies` - cascade deletes
324
+
325
+ **Example Benefit:**
326
+ ```ruby
327
+ class Fact < Sequel::Model
328
+ plugin :timestamps
329
+ plugin :validation_helpers
330
+
331
+ many_to_one :subject, class: :Entity
332
+ one_to_many :provenance_records, class: :Provenance
333
+
334
+ def validate
335
+ super
336
+ validates_presence [:subject_entity_id, :predicate]
337
+ end
338
+ end
339
+ ```
340
+
341
+ ---
342
+
343
+ ## 3. Kent Beck Perspective (TDD, XP, Simple Design)
344
+
345
+ ### Focus Areas
346
+ - Test-first design
347
+ - Simple solutions
348
+ - Revealing intent
349
+ - Small steps
350
+ - Clear boundaries
351
+
352
+ ### Critical Issues
353
+
354
+ #### 🔴 CLI Methods Untestable in Isolation
355
+
356
+ **Problem:** CLI methods create their own dependencies:
357
+ ```ruby
358
+ def ingest
359
+ opts = parse_ingest_options
360
+ return 1 unless opts
361
+
362
+ store = ClaudeMemory::Store::SQLiteStore.new(opts[:db]) # Created here!
363
+ ingester = ClaudeMemory::Ingest::Ingester.new(store) # Created here!
364
+
365
+ result = ingester.ingest(...)
366
+ # ...
367
+ end
368
+ ```
369
+
370
+ **Testing Issues:**
371
+ - Can't inject test double for store
372
+ - Must use real database for tests
373
+ - Slow integration tests required
374
+ - Hard to test error paths
375
+
376
+ **Recommended Fix:**
377
+ ```ruby
378
+ def ingest(store: default_store)
379
+ opts = parse_ingest_options
380
+ return 1 unless opts
381
+
382
+ ingester = ClaudeMemory::Ingest::Ingester.new(store)
383
+ result = ingester.ingest(...)
384
+ # ...
385
+ end
386
+
387
+ private
388
+
389
+ def default_store
390
+ @default_store ||= ClaudeMemory::Store::SQLiteStore.new(opts[:db])
391
+ end
392
+ ```
393
+
394
+ #### 🔴 Methods Don't Reveal Intent
395
+
396
+ **Problem:** `run` method is a giant case statement (cli.rb:14-58):
397
+ ```ruby
398
+ def run
399
+ command = @args.first || "help"
400
+
401
+ case command
402
+ when "help", "-h", "--help"
403
+ print_help
404
+ 0
405
+ when "version", "-v", "--version"
406
+ print_version
407
+ 0
408
+ # ... 15 more cases
409
+ end
410
+ end
411
+ ```
412
+
413
+ **Issues:**
414
+ - Doesn't reveal what the CLI does
415
+ - Adding commands requires modifying this method
416
+ - No clear command structure
417
+
418
+ **Recommended Fix:**
419
+ ```ruby
420
+ def run
421
+ command_name = extract_command_name
422
+ command = find_command(command_name)
423
+ command.call(arguments)
424
+ end
425
+
426
+ private
427
+
428
+ def find_command(name)
429
+ COMMANDS.fetch(name) { UnknownCommand.new(name) }
430
+ end
431
+
432
+ COMMANDS = {
433
+ 'help' => Commands::Help.new(@stdout),
434
+ 'ingest' => Commands::Ingest.new(@stdout, @stderr),
435
+ # ...
436
+ }
437
+ ```
438
+
439
+ #### 🔴 Complex Boolean Logic (cli.rb:124-125)
440
+
441
+ **Problem:**
442
+ ```ruby
443
+ opts[:global] = true if !opts[:global] && !opts[:project]
444
+ opts[:project] = true if !opts[:global] && !opts[:project]
445
+ ```
446
+
447
+ **Issues:**
448
+ - Double negative logic
449
+ - Duplicate condition
450
+ - Intent unclear (setting both to true?)
451
+ - Bug: both will be true after these lines!
452
+
453
+ **Fix:**
454
+ ```ruby
455
+ if !opts[:global] && !opts[:project]
456
+ opts[:global] = true
457
+ opts[:project] = true
458
+ end
459
+ ```
460
+
461
+ Better:
462
+ ```ruby
463
+ opts[:global] = opts[:project] = true if opts.values_at(:global, :project).none?
464
+ ```
465
+
466
+ #### 🟡 Side Effects Hidden in Constructor (index/lexical_fts.rb:6-10)
467
+
468
+ **Problem:**
469
+ ```ruby
470
+ def initialize(store)
471
+ @store = store
472
+ @db = store.db
473
+ ensure_fts_table! # Side effect!
474
+ end
475
+ ```
476
+
477
+ **Issues:**
478
+ - Constructor has side effect (creates table)
479
+ - Violates Command-Query Separation
480
+ - Can't instantiate without modifying database
481
+ - Hard to test
482
+
483
+ **Recommended Fix:**
484
+ ```ruby
485
+ def initialize(store)
486
+ @store = store
487
+ @db = store.db
488
+ end
489
+
490
+ def index_content_item(content_item_id, text)
491
+ ensure_fts_table! # Lazy initialization
492
+ # ...
493
+ end
494
+ ```
495
+
496
+ Or better: separate schema setup from usage.
497
+
498
+ #### 🟡 No Clear Separation of Concerns
499
+
500
+ **Problem:** Parser, validator, executor, formatter all in one method:
501
+ ```ruby
502
+ def recall_cmd
503
+ # Parse
504
+ query = @args[1]
505
+
506
+ # Validate
507
+ unless query
508
+ @stderr.puts "Usage: ..."
509
+ return 1
510
+ end
511
+
512
+ # Parse options
513
+ opts = {limit: 10, scope: "all"}
514
+ OptionParser.new do |o|
515
+ # ...
516
+ end
517
+
518
+ # Execute
519
+ manager = ClaudeMemory::Store::StoreManager.new
520
+ recall = ClaudeMemory::Recall.new(manager)
521
+ results = recall.query(query, limit: opts[:limit], scope: opts[:scope])
522
+
523
+ # Format
524
+ if results.empty?
525
+ @stdout.puts "No facts found."
526
+ else
527
+ results.each do |result|
528
+ print_fact(result[:fact])
529
+ # ...
530
+ end
531
+ end
532
+
533
+ # Cleanup
534
+ manager.close
535
+ 0
536
+ end
537
+ ```
538
+
539
+ **Recommended:** Extract to separate objects (Parser, Validator, Executor, Formatter).
540
+
541
+ ---
542
+
543
+ ## 4. Avdi Grimm Perspective (Confident Ruby)
544
+
545
+ ### Focus Areas
546
+ - Confident code
547
+ - Tell, don't ask
548
+ - Null object pattern
549
+ - Duck typing
550
+ - Meaningful return values
551
+
552
+ ### Critical Issues
553
+
554
+ #### 🔴 Nil Checks Throughout (recall.rb)
555
+
556
+ **Problem:**
557
+ ```ruby
558
+ def explain(fact_id, scope: nil)
559
+ # ...
560
+ explain_from_store(store, fact_id)
561
+ end
562
+
563
+ def explain_from_store(store, fact_id)
564
+ fact = find_fact_from_store(store, fact_id)
565
+ return nil unless fact # Returning nil!
566
+
567
+ {
568
+ fact: fact,
569
+ receipts: find_receipts_from_store(store, fact_id),
570
+ # ...
571
+ }
572
+ end
573
+ ```
574
+
575
+ **Issues:**
576
+ - Caller must check for nil
577
+ - Forces defensive programming everywhere
578
+ - No clear "not found" semantics
579
+
580
+ **Recommended Fix:**
581
+ ```ruby
582
+ class NullExplanation
583
+ def fact
584
+ NullFact.new
585
+ end
586
+
587
+ def receipts
588
+ []
589
+ end
590
+
591
+ def present?
592
+ false
593
+ end
594
+ end
595
+
596
+ def explain_from_store(store, fact_id)
597
+ fact = find_fact_from_store(store, fact_id)
598
+ return NullExplanation.new unless fact
599
+
600
+ Explanation.new(
601
+ fact: fact,
602
+ receipts: find_receipts_from_store(store, fact_id),
603
+ # ...
604
+ )
605
+ end
606
+ ```
607
+
608
+ #### 🔴 Inconsistent Return Values
609
+
610
+ **Problem:** Different methods return different types:
611
+ ```ruby
612
+ # Returns integer exit code
613
+ def ingest
614
+ # ...
615
+ 0
616
+ end
617
+
618
+ # Returns hash
619
+ def promote_fact(fact_id)
620
+ # ...
621
+ global_fact_id
622
+ end
623
+
624
+ # Returns nil or hash
625
+ def explain_from_store(store, fact_id)
626
+ return nil unless fact
627
+ { fact: fact, ... }
628
+ end
629
+ ```
630
+
631
+ **Issues:**
632
+ - No consistent interface
633
+ - Callers can't rely on duck typing
634
+ - Some return success/failure, others return values
635
+
636
+ **Recommended Fix:**
637
+ Use result objects:
638
+ ```ruby
639
+ class Result
640
+ def self.success(value)
641
+ Success.new(value)
642
+ end
643
+
644
+ def self.failure(error)
645
+ Failure.new(error)
646
+ end
647
+ end
648
+
649
+ def promote_fact(fact_id)
650
+ ensure_both!
651
+
652
+ fact = @project_store.facts.where(id: fact_id).first
653
+ return Result.failure("Fact not found") unless fact
654
+
655
+ global_fact_id = # ... promotion logic
656
+ Result.success(global_fact_id)
657
+ end
658
+ ```
659
+
660
+ #### 🔴 Ask-Then-Do Pattern (publish.rb:165-171)
661
+
662
+ **Problem:**
663
+ ```ruby
664
+ def should_write?(path, content)
665
+ return true unless File.exist?(path)
666
+
667
+ existing_hash = Digest::SHA256.file(path).hexdigest
668
+ new_hash = Digest::SHA256.hexdigest(content)
669
+ existing_hash != new_hash
670
+ end
671
+
672
+ # Usage:
673
+ if should_write?(path, content)
674
+ File.write(path, content)
675
+ end
676
+ ```
677
+
678
+ **Issues:**
679
+ - Asking for permission, then doing action
680
+ - Should just "tell" the object to write
681
+
682
+ **Recommended Fix:**
683
+ ```ruby
684
+ class SmartWriter
685
+ def write_if_changed(path, content)
686
+ return :unchanged if unchanged?(path, content)
687
+
688
+ File.write(path, content)
689
+ :written
690
+ end
691
+
692
+ private
693
+
694
+ def unchanged?(path, content)
695
+ File.exist?(path) &&
696
+ Digest::SHA256.file(path).hexdigest == Digest::SHA256.hexdigest(content)
697
+ end
698
+ end
699
+ ```
700
+
701
+ #### 🟡 Early Returns Scattered (resolver.rb:60-73)
702
+
703
+ **Problem:**
704
+ ```ruby
705
+ def resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
706
+ # ...
707
+ if PredicatePolicy.single?(predicate) && existing_facts.any?
708
+ matching = existing_facts.find { |f| values_match?(f, object_val, object_entity_id) }
709
+ if matching
710
+ add_provenance(matching[:id], content_item_id, fact_data)
711
+ outcome[:provenance] = 1
712
+ return outcome # Early return 1
713
+ elsif supersession_signal?(fact_data)
714
+ supersede_facts(existing_facts, occurred_at)
715
+ outcome[:superseded] = existing_facts.size
716
+ else
717
+ create_conflict(existing_facts.first[:id], fact_data, subject_id, content_item_id, occurred_at)
718
+ outcome[:conflicts] = 1
719
+ return outcome # Early return 2
720
+ end
721
+ end
722
+
723
+ # ... continues
724
+ end
725
+ ```
726
+
727
+ **Issues:**
728
+ - Multiple exit points make flow hard to follow
729
+ - Hard to ensure cleanup
730
+ - Nested conditionals
731
+
732
+ **Recommended Fix:**
733
+ Extract to guard clauses at top:
734
+ ```ruby
735
+ def resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
736
+ outcome = build_outcome
737
+
738
+ return handle_matching_fact(...) if matching_fact_exists?(...)
739
+ return handle_conflict(...) if conflicts_with_existing?(...)
740
+
741
+ create_new_fact(...)
742
+ end
743
+ ```
744
+
745
+ #### 🟡 Primitive Obsession
746
+
747
+ **Problem:** Domain concepts represented as hashes:
748
+ ```ruby
749
+ fact = {
750
+ subject_name: "repo",
751
+ predicate: "uses_database",
752
+ object_literal: "PostgreSQL",
753
+ status: "active",
754
+ confidence: 1.0
755
+ }
756
+ ```
757
+
758
+ **Issues:**
759
+ - No domain behavior
760
+ - No validation
761
+ - No encapsulation
762
+ - Hard to refactor
763
+
764
+ **Recommended Fix:**
765
+ ```ruby
766
+ class Fact
767
+ attr_reader :subject_name, :predicate, :object_literal, :status, :confidence
768
+
769
+ def initialize(subject_name:, predicate:, object_literal:, status: "active", confidence: 1.0)
770
+ @subject_name = subject_name
771
+ @predicate = predicate
772
+ @object_literal = object_literal
773
+ @status = status
774
+ @confidence = confidence
775
+
776
+ validate!
777
+ end
778
+
779
+ def active?
780
+ status == "active"
781
+ end
782
+
783
+ def superseded?
784
+ status == "superseded"
785
+ end
786
+
787
+ private
788
+
789
+ def validate!
790
+ raise ArgumentError, "predicate required" if predicate.nil?
791
+ raise ArgumentError, "confidence must be 0-1" unless (0..1).cover?(confidence)
792
+ end
793
+ end
794
+ ```
795
+
796
+ ---
797
+
798
+ ## 5. Gary Bernhardt Perspective (Boundaries, Fast Tests)
799
+
800
+ ### Focus Areas
801
+ - Functional core, imperative shell
802
+ - Fast unit tests
803
+ - Clear boundaries
804
+ - Separation of I/O and logic
805
+ - Value objects
806
+
807
+ ### Critical Issues
808
+
809
+ #### 🔴 I/O Mixed with Logic Throughout CLI
810
+
811
+ **Problem:** Every CLI method mixes computation with I/O:
812
+ ```ruby
813
+ def recall_cmd
814
+ query = @args[1]
815
+ unless query
816
+ @stderr.puts "Usage: ..." # I/O
817
+ return 1
818
+ end
819
+
820
+ opts = {limit: 10, scope: "all"} # Logic
821
+ OptionParser.new do |o| # I/O (arg parsing)
822
+ o.on("--limit N", Integer) { |v| opts[:limit] = v }
823
+ end
824
+
825
+ manager = ClaudeMemory::Store::StoreManager.new # I/O (database)
826
+ recall = ClaudeMemory::Recall.new(manager)
827
+ results = recall.query(query, limit: opts[:limit], scope: opts[:scope]) # Logic
828
+
829
+ if results.empty?
830
+ @stdout.puts "No facts found." # I/O
831
+ else
832
+ @stdout.puts "Found #{results.size} fact(s):\n\n" # I/O
833
+ results.each do |result|
834
+ print_fact(result[:fact]) # I/O
835
+ end
836
+ end
837
+
838
+ manager.close # I/O
839
+ 0
840
+ end
841
+ ```
842
+
843
+ **Issues:**
844
+ - Can't test logic without I/O
845
+ - Slow tests (database required)
846
+ - Hard to test error cases
847
+ - Can't reuse logic in different contexts
848
+
849
+ **Recommended Fix:**
850
+ Functional core:
851
+ ```ruby
852
+ module ClaudeMemory
853
+ module Core
854
+ class RecallQuery
855
+ def self.call(query:, limit:, scope:, facts_repository:)
856
+ facts = facts_repository.search(query, limit: limit, scope: scope)
857
+
858
+ {
859
+ found: facts.any?,
860
+ count: facts.size,
861
+ facts: facts.map { |f| FactPresenter.new(f) }
862
+ }
863
+ end
864
+ end
865
+ end
866
+ end
867
+ ```
868
+
869
+ Imperative shell:
870
+ ```ruby
871
+ def recall_cmd
872
+ params = RecallParams.parse(@args)
873
+ return usage_error unless params.valid?
874
+
875
+ manager = StoreManager.new
876
+ result = Core::RecallQuery.call(
877
+ query: params.query,
878
+ limit: params.limit,
879
+ scope: params.scope,
880
+ facts_repository: FactsRepository.new(manager)
881
+ )
882
+
883
+ output_result(result)
884
+ manager.close
885
+ 0
886
+ end
887
+ ```
888
+
889
+ **Benefits:**
890
+ - Core logic is pure (no I/O)
891
+ - Fast unit tests for core
892
+ - Shell handles all I/O
893
+ - Easy to test edge cases
894
+
895
+ #### 🔴 No Value Objects
896
+
897
+ **Problem:** Primitive types used everywhere:
898
+ ```ruby
899
+ def ingest(source:, session_id:, transcript_path:, project_path: nil)
900
+ # All strings - no domain meaning
901
+ end
902
+ ```
903
+
904
+ **Issues:**
905
+ - No type safety
906
+ - Easy to swap arguments
907
+ - No validation
908
+ - No domain behavior
909
+
910
+ **Recommended Fix:**
911
+ ```ruby
912
+ class SessionId
913
+ attr_reader :value
914
+
915
+ def initialize(value)
916
+ @value = value
917
+ validate!
918
+ end
919
+
920
+ def to_s
921
+ value
922
+ end
923
+
924
+ private
925
+
926
+ def validate!
927
+ raise ArgumentError, "Session ID cannot be empty" if value.nil? || value.empty?
928
+ end
929
+ end
930
+
931
+ class TranscriptPath
932
+ attr_reader :value
933
+
934
+ def initialize(value)
935
+ @value = Pathname.new(value)
936
+ validate!
937
+ end
938
+
939
+ def exist?
940
+ value.exist?
941
+ end
942
+
943
+ private
944
+
945
+ def validate!
946
+ raise ArgumentError, "Path cannot be nil" if value.nil?
947
+ end
948
+ end
949
+
950
+ # Usage:
951
+ def ingest(source:, session_id:, transcript_path:, project_path: nil)
952
+ session_id = SessionId.new(session_id) unless session_id.is_a?(SessionId)
953
+ transcript_path = TranscriptPath.new(transcript_path) unless transcript_path.is_a?(TranscriptPath)
954
+
955
+ # Now have type safety and validation
956
+ end
957
+ ```
958
+
959
+ #### 🔴 Direct File I/O in Business Logic
960
+
961
+ **Problem:** Publish class directly reads/writes files:
962
+ ```ruby
963
+ def should_write?(path, content)
964
+ return true unless File.exist?(path) # Direct file I/O
965
+
966
+ existing_hash = Digest::SHA256.file(path).hexdigest # Direct file I/O
967
+ # ...
968
+ end
969
+
970
+ def ensure_import_exists(mode, path)
971
+ if File.exist?(claude_md) # Direct file I/O
972
+ content = File.read(claude_md) # Direct file I/O
973
+ # ...
974
+ end
975
+ end
976
+ ```
977
+
978
+ **Issues:**
979
+ - Can't test without filesystem
980
+ - Slow tests
981
+ - Hard to test error conditions
982
+
983
+ **Recommended Fix:**
984
+ Inject file system adapter:
985
+ ```ruby
986
+ class FileSystem
987
+ def exist?(path)
988
+ File.exist?(path)
989
+ end
990
+
991
+ def read(path)
992
+ File.read(path)
993
+ end
994
+
995
+ def write(path, content)
996
+ File.write(path, content)
997
+ end
998
+
999
+ def file_hash(path)
1000
+ Digest::SHA256.file(path).hexdigest
1001
+ end
1002
+ end
1003
+
1004
+ class InMemoryFileSystem
1005
+ def initialize
1006
+ @files = {}
1007
+ end
1008
+
1009
+ def exist?(path)
1010
+ @files.key?(path)
1011
+ end
1012
+
1013
+ def read(path)
1014
+ @files.fetch(path) { raise Errno::ENOENT }
1015
+ end
1016
+
1017
+ def write(path, content)
1018
+ @files[path] = content
1019
+ end
1020
+
1021
+ def file_hash(path)
1022
+ content = read(path)
1023
+ Digest::SHA256.hexdigest(content)
1024
+ end
1025
+ end
1026
+
1027
+ class Publish
1028
+ def initialize(store, file_system: FileSystem.new)
1029
+ @store = store
1030
+ @file_system = file_system
1031
+ end
1032
+
1033
+ def should_write?(path, content)
1034
+ return true unless @file_system.exist?(path)
1035
+
1036
+ existing_hash = @file_system.file_hash(path)
1037
+ new_hash = Digest::SHA256.hexdigest(content)
1038
+ existing_hash != new_hash
1039
+ end
1040
+ end
1041
+ ```
1042
+
1043
+ **Test:**
1044
+ ```ruby
1045
+ RSpec.describe Publish do
1046
+ it "writes when file doesn't exist" do
1047
+ fs = InMemoryFileSystem.new
1048
+ store = double(:store)
1049
+ publish = Publish.new(store, file_system: fs)
1050
+
1051
+ # Fast, no real filesystem
1052
+ end
1053
+ end
1054
+ ```
1055
+
1056
+ #### 🔴 State Stored in Instance Variables (resolver.rb:10-13)
1057
+
1058
+ **Problem:**
1059
+ ```ruby
1060
+ def apply(extraction, content_item_id: nil, occurred_at: nil, project_path: nil, scope: "project")
1061
+ occurred_at ||= Time.now.utc.iso8601
1062
+ @current_project_path = project_path # Mutable state!
1063
+ @current_scope = scope # Mutable state!
1064
+
1065
+ # Used in private methods
1066
+ end
1067
+
1068
+ def resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
1069
+ # ... uses @current_project_path and @current_scope
1070
+ fact_scope = fact_data[:scope_hint] || @current_scope
1071
+ fact_project = (fact_scope == "global") ? nil : @current_project_path
1072
+ end
1073
+ ```
1074
+
1075
+ **Issues:**
1076
+ - Hidden coupling between methods
1077
+ - Stateful object (not thread-safe)
1078
+ - Hard to reason about
1079
+ - Side effects on instance
1080
+
1081
+ **Recommended Fix:**
1082
+ Pass as parameters:
1083
+ ```ruby
1084
+ def apply(extraction, content_item_id: nil, occurred_at: nil, project_path: nil, scope: "project")
1085
+ occurred_at ||= Time.now.utc.iso8601
1086
+
1087
+ context = ResolutionContext.new(
1088
+ project_path: project_path,
1089
+ scope: scope,
1090
+ occurred_at: occurred_at
1091
+ )
1092
+
1093
+ result = build_result
1094
+
1095
+ extraction.facts.each do |fact_data|
1096
+ outcome = resolve_fact(fact_data, entity_ids, content_item_id, context)
1097
+ merge_outcome!(result, outcome)
1098
+ end
1099
+
1100
+ result
1101
+ end
1102
+
1103
+ def resolve_fact(fact_data, entity_ids, content_item_id, context)
1104
+ # Uses context parameter instead of instance variables
1105
+ fact_scope = fact_data[:scope_hint] || context.scope
1106
+ fact_project = (fact_scope == "global") ? nil : context.project_path
1107
+ end
1108
+ ```
1109
+
1110
+ #### 🟡 No Clear Layer Boundaries
1111
+
1112
+ **Problem:** Classes don't follow clear architectural layers:
1113
+ ```
1114
+ CLI → creates Store directly
1115
+ CLI → creates Ingester directly
1116
+ Ingester → creates FTS index
1117
+ Publish → reads files
1118
+ Hook::Handler → creates dependencies
1119
+ ```
1120
+
1121
+ **Recommended Architecture:**
1122
+ ```
1123
+ Presentation Layer (CLI, HTTP)
1124
+
1125
+ Application Layer (Use Cases / Commands)
1126
+
1127
+ Domain Layer (Core business logic - pure)
1128
+
1129
+ Infrastructure Layer (Database, Files, External APIs)
1130
+ ```
1131
+
1132
+ **Example:**
1133
+ ```ruby
1134
+ # Domain Layer - Pure logic
1135
+ module ClaudeMemory
1136
+ module Domain
1137
+ class Fact
1138
+ # Pure domain object
1139
+ end
1140
+
1141
+ class FactRepository
1142
+ # Interface (abstract)
1143
+ def find(id)
1144
+ raise NotImplementedError
1145
+ end
1146
+
1147
+ def save(fact)
1148
+ raise NotImplementedError
1149
+ end
1150
+ end
1151
+ end
1152
+ end
1153
+
1154
+ # Infrastructure Layer
1155
+ module ClaudeMemory
1156
+ module Infrastructure
1157
+ class SequelFactRepository < Domain::FactRepository
1158
+ def initialize(db)
1159
+ @db = db
1160
+ end
1161
+
1162
+ def find(id)
1163
+ # Sequel-specific implementation
1164
+ end
1165
+
1166
+ def save(fact)
1167
+ # Sequel-specific implementation
1168
+ end
1169
+ end
1170
+ end
1171
+ end
1172
+
1173
+ # Application Layer
1174
+ module ClaudeMemory
1175
+ module Application
1176
+ class PromoteFact
1177
+ def initialize(fact_repository:, event_publisher:)
1178
+ @fact_repository = fact_repository
1179
+ @event_publisher = event_publisher
1180
+ end
1181
+
1182
+ def call(fact_id)
1183
+ fact = @fact_repository.find(fact_id)
1184
+ return Result.failure("Not found") unless fact
1185
+
1186
+ promoted = fact.promote_to_global
1187
+ @fact_repository.save(promoted)
1188
+ @event_publisher.publish(FactPromoted.new(fact_id))
1189
+
1190
+ Result.success(promoted.id)
1191
+ end
1192
+ end
1193
+ end
1194
+ end
1195
+
1196
+ # Presentation Layer
1197
+ class CLI
1198
+ def promote_cmd
1199
+ fact_id = @args[1]&.to_i
1200
+ return usage_error unless valid_fact_id?(fact_id)
1201
+
1202
+ result = @promote_fact_use_case.call(fact_id)
1203
+
1204
+ if result.success?
1205
+ @stdout.puts "Promoted fact ##{fact_id}"
1206
+ 0
1207
+ else
1208
+ @stderr.puts result.error
1209
+ 1
1210
+ end
1211
+ end
1212
+ end
1213
+ ```
1214
+
1215
+ ---
1216
+
1217
+ ## 6. General Ruby Idioms and Style Issues
1218
+
1219
+ ### 🟡 Inconsistent Method Call Parentheses
1220
+
1221
+ **Problem:**
1222
+ ```ruby
1223
+ @stdout.puts "Message" # No parens
1224
+ print_help # No parens
1225
+ manager.close # No parens
1226
+ opts = {limit: 10, scope: "all"} # No parens
1227
+
1228
+ OptionParser.new do |o| # Parens with block
1229
+ o.on("--limit N", Integer) { |v| opts[:limit] = v } # Parens
1230
+ end
1231
+
1232
+ manager = ClaudeMemory::Store::StoreManager.new # Parens
1233
+ ```
1234
+
1235
+ **Recommendation:** Be consistent. Common Ruby style:
1236
+ - Use parens for methods with arguments
1237
+ - Omit for methods without arguments
1238
+ - Omit for keywords (`puts`, `print`, `raise`)
1239
+
1240
+ ### 🟡 Long Parameter Lists
1241
+
1242
+ **Problem:**
1243
+ ```ruby
1244
+ def upsert_content_item(source:, text_hash:, byte_len:, session_id: nil, transcript_path: nil,
1245
+ project_path: nil, occurred_at: nil, raw_text: nil, metadata: nil)
1246
+ # 9 parameters!
1247
+ end
1248
+
1249
+ def insert_fact(subject_entity_id:, predicate:, object_entity_id: nil, object_literal: nil,
1250
+ datatype: nil, polarity: "positive", valid_from: nil, status: "active",
1251
+ confidence: 1.0, created_from: nil, scope: "project", project_path: nil)
1252
+ # 12 parameters!
1253
+ end
1254
+ ```
1255
+
1256
+ **Recommendation:** Use parameter objects:
1257
+ ```ruby
1258
+ class ContentItemParams
1259
+ attr_reader :source, :text_hash, :byte_len, :session_id, :transcript_path,
1260
+ :project_path, :occurred_at, :raw_text, :metadata
1261
+
1262
+ def initialize(source:, text_hash:, byte_len:, **optional)
1263
+ @source = source
1264
+ @text_hash = text_hash
1265
+ @byte_len = byte_len
1266
+ @session_id = optional[:session_id]
1267
+ # ... etc
1268
+ end
1269
+ end
1270
+
1271
+ def upsert_content_item(params)
1272
+ # Much cleaner
1273
+ end
1274
+ ```
1275
+
1276
+ ### 🟡 Mixed Hash Access (Symbols vs Strings)
1277
+
1278
+ **Problem:**
1279
+ ```ruby
1280
+ # MCP Server
1281
+ request["id"] # String key
1282
+ request["method"] # String key
1283
+
1284
+ # Domain
1285
+ fact[:subject_name] # Symbol key
1286
+ fact[:predicate] # Symbol key
1287
+ ```
1288
+
1289
+ **Recommendation:** Be consistent. Use symbols for internal hashes, strings for external JSON.
1290
+
1291
+ ### 🟡 Rescue Without Specific Exception
1292
+
1293
+ **Problem:**
1294
+ ```ruby
1295
+ begin
1296
+ store = ClaudeMemory::Store::SQLiteStore.new(db_path)
1297
+ # ...
1298
+ rescue => e # Catches everything!
1299
+ issues << "#{label} database error: #{e.message}"
1300
+ end
1301
+ ```
1302
+
1303
+ **Recommendation:** Catch specific exceptions:
1304
+ ```ruby
1305
+ rescue Sequel::DatabaseError, SQLite3::Exception => e
1306
+ issues << "#{label} database error: #{e.message}"
1307
+ end
1308
+ ```
1309
+
1310
+ ### 🟡 ENV Access Scattered Throughout
1311
+
1312
+ **Problem:**
1313
+ ```ruby
1314
+ # claude_memory.rb:28
1315
+ home = env["HOME"] || File.expand_path("~")
1316
+
1317
+ # store_manager.rb:11
1318
+ @project_path = project_path || env["CLAUDE_PROJECT_DIR"] || Dir.pwd
1319
+
1320
+ # hook/handler.rb:16
1321
+ session_id = payload["session_id"] || @env["CLAUDE_SESSION_ID"]
1322
+ ```
1323
+
1324
+ **Recommendation:** Centralize environment access:
1325
+ ```ruby
1326
+ module ClaudeMemory
1327
+ class Configuration
1328
+ def initialize(env = ENV)
1329
+ @env = env
1330
+ end
1331
+
1332
+ def home_dir
1333
+ @env["HOME"] || File.expand_path("~")
1334
+ end
1335
+
1336
+ def project_dir
1337
+ @env["CLAUDE_PROJECT_DIR"] || Dir.pwd
1338
+ end
1339
+
1340
+ def session_id
1341
+ @env["CLAUDE_SESSION_ID"]
1342
+ end
1343
+ end
1344
+ end
1345
+ ```
1346
+
1347
+ ### 🟡 Boolean Traps
1348
+
1349
+ **Problem:**
1350
+ ```ruby
1351
+ opts = {global: false, project: false}
1352
+
1353
+ # What does this mean?
1354
+ manager.ensure_global!
1355
+ manager.ensure_project!
1356
+
1357
+ # What does true/false mean here?
1358
+ if opts[:global]
1359
+ # ...
1360
+ end
1361
+ ```
1362
+
1363
+ **Recommendation:** Use explicit values:
1364
+ ```ruby
1365
+ scope = opts[:scope] # :global, :project, or :both
1366
+
1367
+ case scope
1368
+ when :global
1369
+ manager.ensure_global!
1370
+ when :project
1371
+ manager.ensure_project!
1372
+ when :both
1373
+ manager.ensure_both!
1374
+ end
1375
+ ```
1376
+
1377
+ ### 🟡 No Use of Ruby 3 Features
1378
+
1379
+ **Observations:**
1380
+ - No pattern matching (available since Ruby 2.7)
1381
+ - No rightward assignment
1382
+ - No endless method definitions
1383
+ - No type annotations (RBS/Sorbet)
1384
+
1385
+ **Opportunities:**
1386
+ ```ruby
1387
+ # Current
1388
+ case command
1389
+ when "help", "-h", "--help"
1390
+ print_help
1391
+ when "version", "-v", "--version"
1392
+ print_version
1393
+ end
1394
+
1395
+ # With pattern matching
1396
+ case command
1397
+ in "help" | "-h" | "--help"
1398
+ print_help
1399
+ in "version" | "-v" | "--version"
1400
+ print_version
1401
+ in unknown
1402
+ handle_unknown(unknown)
1403
+ end
1404
+
1405
+ # Current
1406
+ def valid?(fact)
1407
+ fact[:predicate] && fact[:subject_entity_id]
1408
+ end
1409
+
1410
+ # Endless method
1411
+ def valid?(fact) = fact[:predicate] && fact[:subject_entity_id]
1412
+ ```
1413
+
1414
+ ---
1415
+
1416
+ ## 7. Positive Observations
1417
+
1418
+ Despite the issues above, this codebase has several strengths:
1419
+
1420
+ ### ✅ Good Practices
1421
+
1422
+ 1. **Frozen String Literals** - Every file has `# frozen_string_literal: true`
1423
+ 2. **Consistent Sequel Usage** - Most of the time uses Sequel datasets properly
1424
+ 3. **Explicit Dependencies** - Constructor injection used (though inconsistently)
1425
+ 4. **Module Namespacing** - Good use of nested modules
1426
+ 5. **Test Coverage** - Spec files exist for most modules
1427
+ 6. **Documentation** - Good README and CLAUDE.md files
1428
+ 7. **Schema Versioning** - Database has schema version tracking
1429
+ 8. **Error Classes** - Custom error classes defined
1430
+ 9. **Keyword Arguments** - Modern Ruby style with keyword arguments
1431
+ 10. **FTS Integration** - Good use of SQLite's FTS5 capabilities
1432
+
1433
+ ---
1434
+
1435
+ ## 8. Priority Refactoring Recommendations
1436
+
1437
+ ### High Priority (Week 1-2)
1438
+
1439
+ 1. **Extract CLI Command Objects**
1440
+ - Target: Reduce cli.rb from 867 lines to < 200
1441
+ - Extract each command to separate class
1442
+ - Use command pattern
1443
+
1444
+ 2. **Add Transaction Safety**
1445
+ - Wrap `promote_fact` in transaction
1446
+ - Wrap resolver operations in transactions
1447
+ - Add rollback tests
1448
+
1449
+ 3. **Fix Raw SQL in doctor_cmd**
1450
+ - Replace with Sequel dataset methods
1451
+ - Ensures consistency
1452
+
1453
+ 4. **Separate I/O from Logic in Core Classes**
1454
+ - Start with Recall, Publish
1455
+ - Extract functional core
1456
+ - Make imperativeshell thin
1457
+
1458
+ ### Medium Priority (Week 3-4)
1459
+
1460
+ 5. **Introduce Value Objects**
1461
+ - SessionId, TranscriptPath, FactId
1462
+ - Adds type safety
1463
+ - Documents domain
1464
+
1465
+ 6. **Replace Nil Returns with Null Objects**
1466
+ - NullExplanation, NullFact
1467
+ - Enables confident code
1468
+ - Reduces nil checks
1469
+
1470
+ 7. **Extract Repository Pattern**
1471
+ - FactRepository, EntityRepository
1472
+ - Abstracts data access
1473
+ - Enables testing without database
1474
+
1475
+ 8. **Split Recall into Legacy/Dual**
1476
+ - Remove conditional mode logic
1477
+ - Clearer single responsibility
1478
+ - Easier to maintain
1479
+
1480
+ ### Low Priority (Week 5+)
1481
+
1482
+ 9. **Add Domain Models**
1483
+ - Fact, Entity, Provenance classes
1484
+ - Rich domain behavior
1485
+ - Replace primitive hashes
1486
+
1487
+ 10. **Introduce Proper Migrations**
1488
+ - Use Sequel migration framework
1489
+ - Versioned, reversible
1490
+ - Development/production parity
1491
+
1492
+ 11. **Add Type Annotations**
1493
+ - Consider RBS or Sorbet
1494
+ - Better IDE support
1495
+ - Catches type errors early
1496
+
1497
+ 12. **Centralize Configuration**
1498
+ - Configuration class
1499
+ - Environment variable access
1500
+ - Testable, mockable
1501
+
1502
+ ---
1503
+
1504
+ ## 9. Conclusion
1505
+
1506
+ This codebase shows solid Ruby fundamentals but suffers from common growing pains: God Objects, mixed concerns, and lack of architectural boundaries. The issues are fixable and follow predictable patterns.
1507
+
1508
+ **Key Takeaways:**
1509
+ 1. **CLI needs major refactoring** - Extract command objects
1510
+ 2. **Separate I/O from logic** - Enable fast tests
1511
+ 3. **Use transactions** - Data integrity
1512
+ 4. **Introduce domain objects** - Replace primitive hashes
1513
+ 5. **Adopt null object pattern** - Reduce nil checks
1514
+
1515
+ **Estimated Refactoring Effort:**
1516
+ - High priority: 2 weeks (1 developer)
1517
+ - Medium priority: 2 weeks (1 developer)
1518
+ - Low priority: 1-2 weeks (1 developer)
1519
+ - Total: 5-6 weeks for comprehensive refactoring
1520
+
1521
+ **Risk Assessment:** Low-to-medium risk. Changes are incremental and testable. Existing test suite provides safety net.
1522
+
1523
+ ---
1524
+
1525
+ ## Appendix A: Recommended Reading
1526
+
1527
+ 1. **Sandi Metz** - _Practical Object-Oriented Design in Ruby_ (POODR)
1528
+ 2. **Jeremy Evans** - _Sequel Documentation_ and _Roda Book_
1529
+ 3. **Kent Beck** - _Test-Driven Development: By Example_
1530
+ 4. **Avdi Grimm** - _Confident Ruby_
1531
+ 5. **Gary Bernhardt** - _Boundaries_ talk, _Destroy All Software_ screencasts
1532
+ 6. **Martin Fowler** - _Refactoring: Ruby Edition_
1533
+
1534
+ ## Appendix B: Quick Wins (Can Do Today)
1535
+
1536
+ 1. Fix raw SQL in `doctor_cmd` (20 minutes)
1537
+ 2. Consolidate `attr_reader` in StoreManager (5 minutes)
1538
+ 3. Fix boolean logic in `parse_db_init_options` (10 minutes)
1539
+ 4. Move `public` declaration in SQLiteStore (2 minutes)
1540
+ 5. Extract long methods in CLI (1 hour per method)
1541
+
1542
+ ---
1543
+
1544
+ **Review completed:** 2026-01-21