claude_memory 0.2.0 → 0.3.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/.mind.mv2.o2N83S +0 -0
  3. data/.claude/CLAUDE.md +1 -0
  4. data/.claude/rules/claude_memory.generated.md +28 -9
  5. data/.claude/settings.local.json +9 -1
  6. data/.claude/skills/check-memory/SKILL.md +77 -0
  7. data/.claude/skills/improve/SKILL.md +532 -0
  8. data/.claude/skills/improve/feature-patterns.md +1221 -0
  9. data/.claude/skills/quality-update/SKILL.md +229 -0
  10. data/.claude/skills/quality-update/implementation-guide.md +346 -0
  11. data/.claude/skills/review-commit/SKILL.md +199 -0
  12. data/.claude/skills/review-for-quality/SKILL.md +154 -0
  13. data/.claude/skills/review-for-quality/expert-checklists.md +79 -0
  14. data/.claude/skills/setup-memory/SKILL.md +168 -0
  15. data/.claude/skills/study-repo/SKILL.md +307 -0
  16. data/.claude/skills/study-repo/analysis-template.md +323 -0
  17. data/.claude/skills/study-repo/focus-examples.md +327 -0
  18. data/CHANGELOG.md +133 -0
  19. data/CLAUDE.md +130 -11
  20. data/README.md +117 -10
  21. data/db/migrations/001_create_initial_schema.rb +117 -0
  22. data/db/migrations/002_add_project_scoping.rb +33 -0
  23. data/db/migrations/003_add_session_metadata.rb +42 -0
  24. data/db/migrations/004_add_fact_embeddings.rb +20 -0
  25. data/db/migrations/005_add_incremental_sync.rb +21 -0
  26. data/db/migrations/006_add_operation_tracking.rb +40 -0
  27. data/db/migrations/007_add_ingestion_metrics.rb +26 -0
  28. data/docs/.claude/mind.mv2.lock +0 -0
  29. data/docs/GETTING_STARTED.md +587 -0
  30. data/docs/RELEASE_NOTES_v0.2.0.md +0 -1
  31. data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +0 -2
  32. data/docs/architecture.md +9 -8
  33. data/docs/auto_init_design.md +230 -0
  34. data/docs/improvements.md +557 -731
  35. data/docs/influence/.gitkeep +13 -0
  36. data/docs/influence/grepai.md +933 -0
  37. data/docs/influence/qmd.md +2195 -0
  38. data/docs/plugin.md +257 -11
  39. data/docs/quality_review.md +472 -1273
  40. data/docs/remaining_improvements.md +330 -0
  41. data/lefthook.yml +13 -0
  42. data/lib/claude_memory/commands/checks/claude_md_check.rb +41 -0
  43. data/lib/claude_memory/commands/checks/database_check.rb +120 -0
  44. data/lib/claude_memory/commands/checks/hooks_check.rb +112 -0
  45. data/lib/claude_memory/commands/checks/reporter.rb +110 -0
  46. data/lib/claude_memory/commands/checks/snapshot_check.rb +30 -0
  47. data/lib/claude_memory/commands/doctor_command.rb +12 -129
  48. data/lib/claude_memory/commands/help_command.rb +1 -0
  49. data/lib/claude_memory/commands/hook_command.rb +9 -2
  50. data/lib/claude_memory/commands/index_command.rb +169 -0
  51. data/lib/claude_memory/commands/ingest_command.rb +1 -1
  52. data/lib/claude_memory/commands/init_command.rb +5 -197
  53. data/lib/claude_memory/commands/initializers/database_ensurer.rb +30 -0
  54. data/lib/claude_memory/commands/initializers/global_initializer.rb +85 -0
  55. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +156 -0
  56. data/lib/claude_memory/commands/initializers/mcp_configurator.rb +56 -0
  57. data/lib/claude_memory/commands/initializers/memory_instructions_writer.rb +135 -0
  58. data/lib/claude_memory/commands/initializers/project_initializer.rb +111 -0
  59. data/lib/claude_memory/commands/recover_command.rb +75 -0
  60. data/lib/claude_memory/commands/registry.rb +5 -1
  61. data/lib/claude_memory/commands/stats_command.rb +239 -0
  62. data/lib/claude_memory/commands/uninstall_command.rb +226 -0
  63. data/lib/claude_memory/core/batch_loader.rb +32 -0
  64. data/lib/claude_memory/core/concept_ranker.rb +73 -0
  65. data/lib/claude_memory/core/embedding_candidate_builder.rb +37 -0
  66. data/lib/claude_memory/core/fact_collector.rb +51 -0
  67. data/lib/claude_memory/core/fact_query_builder.rb +154 -0
  68. data/lib/claude_memory/core/fact_ranker.rb +113 -0
  69. data/lib/claude_memory/core/result_builder.rb +54 -0
  70. data/lib/claude_memory/core/result_sorter.rb +25 -0
  71. data/lib/claude_memory/core/scope_filter.rb +61 -0
  72. data/lib/claude_memory/core/text_builder.rb +29 -0
  73. data/lib/claude_memory/embeddings/generator.rb +161 -0
  74. data/lib/claude_memory/embeddings/similarity.rb +69 -0
  75. data/lib/claude_memory/hook/handler.rb +4 -3
  76. data/lib/claude_memory/index/lexical_fts.rb +7 -2
  77. data/lib/claude_memory/infrastructure/operation_tracker.rb +158 -0
  78. data/lib/claude_memory/infrastructure/schema_validator.rb +206 -0
  79. data/lib/claude_memory/ingest/content_sanitizer.rb +6 -7
  80. data/lib/claude_memory/ingest/ingester.rb +99 -15
  81. data/lib/claude_memory/ingest/metadata_extractor.rb +57 -0
  82. data/lib/claude_memory/ingest/tool_extractor.rb +71 -0
  83. data/lib/claude_memory/mcp/response_formatter.rb +331 -0
  84. data/lib/claude_memory/mcp/server.rb +19 -0
  85. data/lib/claude_memory/mcp/setup_status_analyzer.rb +73 -0
  86. data/lib/claude_memory/mcp/tool_definitions.rb +279 -0
  87. data/lib/claude_memory/mcp/tool_helpers.rb +80 -0
  88. data/lib/claude_memory/mcp/tools.rb +330 -320
  89. data/lib/claude_memory/recall/dual_query_template.rb +63 -0
  90. data/lib/claude_memory/recall.rb +304 -237
  91. data/lib/claude_memory/resolve/resolver.rb +52 -49
  92. data/lib/claude_memory/store/sqlite_store.rb +210 -144
  93. data/lib/claude_memory/store/store_manager.rb +6 -6
  94. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  95. data/lib/claude_memory/version.rb +1 -1
  96. data/lib/claude_memory.rb +35 -3
  97. metadata +71 -11
  98. data/.claude/.mind.mv2.aLCUZd +0 -0
  99. data/.claude/memory.sqlite3 +0 -0
  100. data/.mcp.json +0 -11
  101. /data/docs/{feature_adoption_plan.md → plans/feature_adoption_plan.md} +0 -0
  102. /data/docs/{feature_adoption_plan_revised.md → plans/feature_adoption_plan_revised.md} +0 -0
  103. /data/docs/{plan.md → plans/plan.md} +0 -0
  104. /data/docs/{updated_plan.md → plans/updated_plan.md} +0 -0
@@ -0,0 +1,1221 @@
1
+ # Feature Implementation Patterns
2
+
3
+ ## Expert Review Applied
4
+
5
+ This guide demonstrates best practices from our Ruby experts:
6
+ - **Sandi Metz**: Small methods, dependency injection, SRP
7
+ - **Jeremy Evans**: DateTime columns, proper Sequel usage
8
+ - **Kent Beck**: Clear intent, testable design
9
+ - **Avdi Grimm**: Confident code, meaningful return values
10
+ - **Gary Bernhardt**: Boundaries, functional core/imperative shell
11
+
12
+ ---
13
+
14
+ ## Common Feature Types & Recipes
15
+
16
+ ### Pattern 1: Adding a Database Table
17
+
18
+ **Use Case**: ROI metrics, tool usage tracking, logs
19
+
20
+ **Recipe:**
21
+ ```ruby
22
+ # 1. Increment SCHEMA_VERSION in sqlite_store.rb
23
+ SCHEMA_VERSION = 7
24
+
25
+ # 2. Add migration method
26
+ def migrate_to_v7!
27
+ @db.create_table?(:ingestion_metrics) do
28
+ primary_key :id
29
+ foreign_key :content_item_id, :content_items, null: false
30
+ Integer :input_tokens, null: false, default: 0
31
+ Integer :output_tokens, null: false, default: 0
32
+ Integer :facts_extracted, null: false, default: 0
33
+ DateTime :created_at, null: false # ✅ Jeremy Evans: Use DateTime, not String
34
+ end
35
+ end
36
+
37
+ # 3. Call in run_migrations!
38
+ def run_migrations!
39
+ current = schema_version
40
+ migrate_to_v7! if current < 7
41
+ update_schema_version(SCHEMA_VERSION)
42
+ end
43
+
44
+ # 4. Add accessor method
45
+ def ingestion_metrics
46
+ @db[:ingestion_metrics]
47
+ end
48
+
49
+ private
50
+
51
+ # ✅ Sandi Metz: Extract small, focused methods
52
+ def schema_version
53
+ @db.fetch("PRAGMA user_version").first[:user_version]
54
+ end
55
+
56
+ def update_schema_version(version)
57
+ @db.run("PRAGMA user_version = #{version}")
58
+ end
59
+ ```
60
+
61
+ **Expert Notes:**
62
+ - ✅ **Jeremy Evans**: DateTime columns are more efficient than String timestamps
63
+ - ✅ **Sandi Metz**: Small helper methods for schema version management
64
+ - ✅ **Kent Beck**: Migration method name reveals intent
65
+
66
+ **Tests:**
67
+ ```ruby
68
+ # spec/claude_memory/store/sqlite_store_spec.rb
69
+ RSpec.describe ClaudeMemory::Store::SQLiteStore do
70
+ describe "schema version 7" do
71
+ it "creates ingestion_metrics table" do
72
+ expect(store.db.table_exists?(:ingestion_metrics)).to be true
73
+ end
74
+
75
+ it "uses DateTime for created_at column" do
76
+ schema = store.db.schema(:ingestion_metrics)
77
+ created_at_column = schema.find { |col| col[0] == :created_at }
78
+
79
+ expect(created_at_column[1][:type]).to eq(:datetime)
80
+ end
81
+
82
+ it "includes all required columns" do
83
+ columns = store.db.schema(:ingestion_metrics).map { |c| c[0] }
84
+ expected = [:id, :content_item_id, :input_tokens, :output_tokens,
85
+ :facts_extracted, :created_at]
86
+
87
+ expect(columns).to match_array(expected)
88
+ end
89
+ end
90
+ end
91
+ ```
92
+
93
+ **Time Estimate**: 15-20 minutes
94
+
95
+ ---
96
+
97
+ ### Pattern 2: Adding a New CLI Command
98
+
99
+ **Use Case**: Stats enhancements, embed command, new utilities
100
+
101
+ **Recipe:**
102
+ ```ruby
103
+ # 1. Create command file
104
+ # lib/claude_memory/commands/metrics_command.rb
105
+ module ClaudeMemory
106
+ module Commands
107
+ class MetricsCommand < BaseCommand
108
+ # ✅ Gary Bernhardt: Inject dependencies, don't create them
109
+ def initialize(stdout: $stdout, stderr: $stderr, store_manager: nil)
110
+ super(stdout: stdout, stderr: stderr)
111
+ @store_manager = store_manager
112
+ end
113
+
114
+ def call(args)
115
+ opts = parse_options(args)
116
+ return 1 if opts.nil?
117
+
118
+ # ✅ Gary Bernhardt: Ensure cleanup even on exception
119
+ manager = store_manager_for(opts)
120
+
121
+ result = execute_command(manager, opts)
122
+ output_result(result, opts)
123
+
124
+ 0
125
+ ensure
126
+ manager&.close
127
+ end
128
+
129
+ private
130
+
131
+ # ✅ Sandi Metz: Small, focused methods
132
+ def parse_options(args)
133
+ opts = default_options
134
+
135
+ OptionParser.new do |parser|
136
+ parser.banner = "Usage: claude-memory metrics [options]"
137
+ parser.on("--format FORMAT", ["text", "json"], "Output format") do |f|
138
+ opts[:format] = f
139
+ end
140
+ end.parse!(args)
141
+
142
+ opts
143
+ rescue OptionParser::InvalidOption => e
144
+ stderr.puts "Error: #{e.message}"
145
+ nil
146
+ end
147
+
148
+ def default_options
149
+ { format: "text" }
150
+ end
151
+
152
+ # ✅ Gary Bernhardt: Factory method for testability
153
+ def store_manager_for(opts)
154
+ @store_manager || Store::StoreManager.new
155
+ end
156
+
157
+ # ✅ Kent Beck: Method name reveals intent
158
+ def execute_command(manager, opts)
159
+ MetricsCalculator.new(manager.global_store).calculate
160
+ end
161
+
162
+ # ✅ Avdi Grimm: Tell, don't ask - formatter knows how to format
163
+ def output_result(result, opts)
164
+ formatter = formatter_for(opts[:format])
165
+ stdout.puts formatter.format(result)
166
+ end
167
+
168
+ def formatter_for(format)
169
+ case format
170
+ when "json"
171
+ JsonMetricsFormatter.new
172
+ else
173
+ TextMetricsFormatter.new
174
+ end
175
+ end
176
+ end
177
+
178
+ # ✅ Gary Bernhardt: Pure calculation logic, no I/O
179
+ class MetricsCalculator
180
+ def initialize(store)
181
+ @store = store
182
+ end
183
+
184
+ def calculate
185
+ {
186
+ total_input_tokens: sum_column(:input_tokens),
187
+ total_output_tokens: sum_column(:output_tokens),
188
+ total_facts: sum_column(:facts_extracted),
189
+ efficiency: calculate_efficiency
190
+ }
191
+ end
192
+
193
+ private
194
+
195
+ def sum_column(column)
196
+ @store.ingestion_metrics.sum(column) || 0
197
+ end
198
+
199
+ def calculate_efficiency
200
+ facts = sum_column(:facts_extracted)
201
+ tokens = sum_column(:input_tokens)
202
+
203
+ return 0.0 if tokens.zero?
204
+
205
+ (facts.to_f / tokens * 1000).round(2)
206
+ end
207
+ end
208
+
209
+ # ✅ Sandi Metz: Single responsibility - knows how to format
210
+ class TextMetricsFormatter
211
+ def format(metrics)
212
+ [
213
+ "Token Economics:",
214
+ " Input tokens: #{metrics[:total_input_tokens]}",
215
+ " Output tokens: #{metrics[:total_output_tokens]}",
216
+ " Facts extracted: #{metrics[:total_facts]}",
217
+ " Efficiency: #{metrics[:efficiency]} facts/1k tokens"
218
+ ].join("\n")
219
+ end
220
+ end
221
+
222
+ class JsonMetricsFormatter
223
+ def format(metrics)
224
+ JSON.pretty_generate(metrics)
225
+ end
226
+ end
227
+ end
228
+ end
229
+
230
+ # 2. Register in registry
231
+ # lib/claude_memory/commands/registry.rb
232
+ COMMANDS = {
233
+ # ... existing
234
+ "metrics" => MetricsCommand
235
+ }.freeze
236
+
237
+ # 3. Add to help text
238
+ # lib/claude_memory/commands/help_command.rb
239
+ " metrics Display token usage and efficiency metrics"
240
+ ```
241
+
242
+ **Expert Notes:**
243
+ - ✅ **Gary Bernhardt**: Dependencies injected, pure calculator logic separated from I/O
244
+ - ✅ **Sandi Metz**: Small classes with single responsibility (calculator, formatters)
245
+ - ✅ **Kent Beck**: Clear method names that reveal intent
246
+ - ✅ **Avdi Grimm**: No nil checks needed, formatters handle their own formatting
247
+
248
+ **Tests:**
249
+ ```ruby
250
+ # spec/claude_memory/commands/metrics_command_spec.rb
251
+ RSpec.describe ClaudeMemory::Commands::MetricsCommand do
252
+ let(:stdout) { StringIO.new }
253
+ let(:stderr) { StringIO.new }
254
+ let(:store_manager) { instance_double(ClaudeMemory::Store::StoreManager) }
255
+ let(:global_store) { instance_double(ClaudeMemory::Store::SQLiteStore) }
256
+ let(:command) do
257
+ described_class.new(
258
+ stdout: stdout,
259
+ stderr: stderr,
260
+ store_manager: store_manager
261
+ )
262
+ end
263
+
264
+ before do
265
+ allow(store_manager).to receive(:global_store).and_return(global_store)
266
+ allow(store_manager).to receive(:close)
267
+ end
268
+
269
+ describe "#call" do
270
+ let(:metrics_dataset) { double("metrics_dataset") }
271
+
272
+ before do
273
+ allow(global_store).to receive(:ingestion_metrics).and_return(metrics_dataset)
274
+ allow(metrics_dataset).to receive(:sum).with(:input_tokens).and_return(1000)
275
+ allow(metrics_dataset).to receive(:sum).with(:output_tokens).and_return(500)
276
+ allow(metrics_dataset).to receive(:sum).with(:facts_extracted).and_return(10)
277
+ end
278
+
279
+ it "returns success exit code" do
280
+ expect(command.call([])).to eq(0)
281
+ end
282
+
283
+ it "closes store manager even on exception" do
284
+ allow(global_store).to receive(:ingestion_metrics).and_raise("DB error")
285
+
286
+ expect { command.call([]) }.to raise_error("DB error")
287
+ expect(store_manager).to have_received(:close)
288
+ end
289
+
290
+ it "displays metrics in text format by default" do
291
+ command.call([])
292
+ output = stdout.string
293
+
294
+ expect(output).to include("Token Economics:")
295
+ expect(output).to include("Input tokens: 1000")
296
+ expect(output).to include("Facts extracted: 10")
297
+ expect(output).to include("Efficiency: 10.0 facts/1k tokens")
298
+ end
299
+
300
+ context "with --format json" do
301
+ it "outputs JSON format" do
302
+ command.call(["--format", "json"])
303
+
304
+ output = JSON.parse(stdout.string)
305
+ expect(output["total_input_tokens"]).to eq(1000)
306
+ expect(output["efficiency"]).to eq(10.0)
307
+ end
308
+ end
309
+
310
+ context "with invalid option" do
311
+ it "returns error code" do
312
+ expect(command.call(["--invalid"])).to eq(1)
313
+ expect(stderr.string).to include("Error:")
314
+ end
315
+ end
316
+ end
317
+ end
318
+
319
+ # spec/claude_memory/commands/metrics_calculator_spec.rb
320
+ RSpec.describe ClaudeMemory::Commands::MetricsCalculator do
321
+ let(:store) { instance_double(ClaudeMemory::Store::SQLiteStore) }
322
+ let(:metrics_dataset) { double("metrics_dataset") }
323
+ let(:calculator) { described_class.new(store) }
324
+
325
+ before do
326
+ allow(store).to receive(:ingestion_metrics).and_return(metrics_dataset)
327
+ end
328
+
329
+ describe "#calculate" do
330
+ context "with data" do
331
+ before do
332
+ allow(metrics_dataset).to receive(:sum).with(:input_tokens).and_return(2000)
333
+ allow(metrics_dataset).to receive(:sum).with(:output_tokens).and_return(1000)
334
+ allow(metrics_dataset).to receive(:sum).with(:facts_extracted).and_return(20)
335
+ end
336
+
337
+ it "calculates efficiency correctly" do
338
+ result = calculator.calculate
339
+ expect(result[:efficiency]).to eq(10.0) # 20 facts / 2000 tokens * 1000
340
+ end
341
+ end
342
+
343
+ context "with zero tokens" do
344
+ before do
345
+ allow(metrics_dataset).to receive(:sum).and_return(0)
346
+ end
347
+
348
+ it "returns zero efficiency without dividing by zero" do
349
+ result = calculator.calculate
350
+ expect(result[:efficiency]).to eq(0.0)
351
+ end
352
+ end
353
+ end
354
+ end
355
+ ```
356
+
357
+ **Time Estimate**: 30-40 minutes (includes formatter extraction)
358
+
359
+ ---
360
+
361
+ ### Pattern 3: Adding Columns to Existing Table
362
+
363
+ **Use Case**: Session metadata, enhanced tracking
364
+
365
+ **Recipe:**
366
+ ```ruby
367
+ # 1. Increment schema version
368
+ SCHEMA_VERSION = 8
369
+
370
+ # 2. Add migration
371
+ def migrate_to_v8!
372
+ @db.alter_table :content_items do
373
+ add_column :git_branch, String
374
+ add_column :cwd, String
375
+ add_column :claude_version, String
376
+ end
377
+ end
378
+
379
+ # 3. Create parameter object for content item data
380
+ # ✅ Sandi Metz: Parameter object reduces method signature complexity
381
+ # lib/claude_memory/domain/content_item_params.rb
382
+ module ClaudeMemory
383
+ module Domain
384
+ class ContentItemParams
385
+ attr_reader :source, :text_hash, :byte_len, :session_id,
386
+ :transcript_path, :git_branch, :cwd, :claude_version
387
+
388
+ def initialize(source:, text_hash:, byte_len:, **optional)
389
+ @source = source
390
+ @text_hash = text_hash
391
+ @byte_len = byte_len
392
+ @session_id = optional[:session_id]
393
+ @transcript_path = optional[:transcript_path]
394
+ @git_branch = optional[:git_branch]
395
+ @cwd = optional[:cwd]
396
+ @claude_version = optional[:claude_version]
397
+
398
+ freeze # ✅ Gary Bernhardt: Immutable value object
399
+ end
400
+
401
+ def to_h
402
+ {
403
+ source: source,
404
+ text_hash: text_hash,
405
+ byte_len: byte_len,
406
+ session_id: session_id,
407
+ transcript_path: transcript_path,
408
+ git_branch: git_branch,
409
+ cwd: cwd,
410
+ claude_version: claude_version,
411
+ ingested_at: Time.now.utc # ✅ Jeremy Evans: Use Time object
412
+ }
413
+ end
414
+ end
415
+ end
416
+ end
417
+
418
+ # 4. Update insert method to use parameter object
419
+ def upsert_content_item(params)
420
+ # ✅ Avdi Grimm: Accept parameter object or hash
421
+ params = Domain::ContentItemParams.new(**params) unless params.is_a?(Domain::ContentItemParams)
422
+
423
+ @db[:content_items].insert_conflict(
424
+ target: [:session_id, :text_hash],
425
+ update: { ingested_at: Sequel.function(:datetime, 'now') }
426
+ ).insert(params.to_h)
427
+ end
428
+ ```
429
+
430
+ **Expert Notes:**
431
+ - ✅ **Sandi Metz**: Parameter object eliminates long parameter lists
432
+ - ✅ **Gary Bernhardt**: Immutable value object (frozen)
433
+ - ✅ **Jeremy Evans**: Use Time objects instead of ISO8601 strings
434
+ - ✅ **Avdi Grimm**: Duck typing - accepts object or hash
435
+
436
+ **Tests:**
437
+ ```ruby
438
+ RSpec.describe ClaudeMemory::Domain::ContentItemParams do
439
+ describe "#initialize" do
440
+ it "accepts required parameters" do
441
+ params = described_class.new(
442
+ source: "test",
443
+ text_hash: "abc123",
444
+ byte_len: 100
445
+ )
446
+
447
+ expect(params.source).to eq("test")
448
+ expect(params.text_hash).to eq("abc123")
449
+ expect(params.byte_len).to eq(100)
450
+ end
451
+
452
+ it "accepts optional parameters" do
453
+ params = described_class.new(
454
+ source: "test",
455
+ text_hash: "abc123",
456
+ byte_len: 100,
457
+ git_branch: "main",
458
+ cwd: "/path/to/project"
459
+ )
460
+
461
+ expect(params.git_branch).to eq("main")
462
+ expect(params.cwd).to eq("/path/to/project")
463
+ end
464
+
465
+ it "creates immutable object" do
466
+ params = described_class.new(
467
+ source: "test",
468
+ text_hash: "abc123",
469
+ byte_len: 100
470
+ )
471
+
472
+ expect(params).to be_frozen
473
+ end
474
+ end
475
+
476
+ describe "#to_h" do
477
+ it "converts to hash with timestamp" do
478
+ params = described_class.new(
479
+ source: "test",
480
+ text_hash: "abc123",
481
+ byte_len: 100,
482
+ git_branch: "feature/test"
483
+ )
484
+
485
+ hash = params.to_h
486
+
487
+ expect(hash[:source]).to eq("test")
488
+ expect(hash[:git_branch]).to eq("feature/test")
489
+ expect(hash[:ingested_at]).to be_a(Time)
490
+ end
491
+ end
492
+ end
493
+
494
+ RSpec.describe "upsert_content_item with new columns" do
495
+ it "stores metadata using parameter object" do
496
+ params = ClaudeMemory::Domain::ContentItemParams.new(
497
+ source: "test",
498
+ text_hash: "abc123",
499
+ byte_len: 100,
500
+ git_branch: "feature/test",
501
+ cwd: "/path/to/project"
502
+ )
503
+
504
+ id = store.upsert_content_item(params)
505
+ item = store.content_items.where(id: id).first
506
+
507
+ expect(item[:git_branch]).to eq("feature/test")
508
+ expect(item[:cwd]).to eq("/path/to/project")
509
+ end
510
+
511
+ it "accepts hash for backward compatibility" do
512
+ id = store.upsert_content_item(
513
+ source: "test",
514
+ text_hash: "def456",
515
+ byte_len: 200,
516
+ git_branch: "main"
517
+ )
518
+
519
+ item = store.content_items.where(id: id).first
520
+ expect(item[:git_branch]).to eq("main")
521
+ end
522
+ end
523
+ ```
524
+
525
+ **Time Estimate**: 20-25 minutes (includes parameter object)
526
+
527
+ ---
528
+
529
+ ### Pattern 4: Enhancing Statistics Output
530
+
531
+ **Use Case**: Better reporting, ROI metrics, aggregations
532
+
533
+ **Recipe:**
534
+ ```ruby
535
+ # ✅ Gary Bernhardt: Pure statistics calculator, no I/O
536
+ # lib/claude_memory/domain/statistics_calculator.rb
537
+ module ClaudeMemory
538
+ module Domain
539
+ class StatisticsCalculator
540
+ def initialize(metrics_data)
541
+ @metrics_data = metrics_data
542
+ freeze
543
+ end
544
+
545
+ def calculate
546
+ Statistics.new(
547
+ total_input_tokens: total_input_tokens,
548
+ total_output_tokens: total_output_tokens,
549
+ total_facts: total_facts,
550
+ efficiency: efficiency
551
+ )
552
+ end
553
+
554
+ private
555
+
556
+ def total_input_tokens
557
+ @metrics_data.sum { |m| m[:input_tokens] }
558
+ end
559
+
560
+ def total_output_tokens
561
+ @metrics_data.sum { |m| m[:output_tokens] }
562
+ end
563
+
564
+ def total_facts
565
+ @metrics_data.sum { |m| m[:facts_extracted] }
566
+ end
567
+
568
+ def efficiency
569
+ return 0.0 if total_input_tokens.zero?
570
+
571
+ (total_facts.to_f / total_input_tokens * 1000).round(2)
572
+ end
573
+ end
574
+
575
+ # ✅ Avdi Grimm: Result object instead of hash
576
+ class Statistics
577
+ attr_reader :total_input_tokens, :total_output_tokens, :total_facts, :efficiency
578
+
579
+ def initialize(total_input_tokens:, total_output_tokens:, total_facts:, efficiency:)
580
+ @total_input_tokens = total_input_tokens
581
+ @total_output_tokens = total_output_tokens
582
+ @total_facts = total_facts
583
+ @efficiency = efficiency
584
+ freeze
585
+ end
586
+
587
+ def efficient?
588
+ efficiency > 5.0 # More than 5 facts per 1k tokens
589
+ end
590
+
591
+ def to_h
592
+ {
593
+ total_input_tokens: total_input_tokens,
594
+ total_output_tokens: total_output_tokens,
595
+ total_facts: total_facts,
596
+ efficiency: efficiency
597
+ }
598
+ end
599
+ end
600
+ end
601
+ end
602
+
603
+ # ✅ Sandi Metz: Small methods, single responsibility
604
+ # lib/claude_memory/commands/stats_command.rb
605
+ module ClaudeMemory
606
+ module Commands
607
+ class StatsCommand < BaseCommand
608
+ def call(args)
609
+ manager = Store::StoreManager.new
610
+
611
+ display_basic_stats(manager)
612
+ display_metrics_stats(manager) if has_metrics?(manager)
613
+
614
+ 0
615
+ ensure
616
+ manager&.close
617
+ end
618
+
619
+ private
620
+
621
+ def display_basic_stats(manager)
622
+ stdout.puts "Facts: #{count_facts(manager)}"
623
+ stdout.puts "Entities: #{count_entities(manager)}"
624
+ end
625
+
626
+ def display_metrics_stats(manager)
627
+ stats = calculate_statistics(manager)
628
+ formatter = StatisticsFormatter.new(stdout)
629
+ formatter.format(stats)
630
+ end
631
+
632
+ def has_metrics?(manager)
633
+ manager.global_store.db.table_exists?(:ingestion_metrics)
634
+ end
635
+
636
+ def calculate_statistics(manager)
637
+ metrics_data = manager.global_store.ingestion_metrics.all
638
+ Domain::StatisticsCalculator.new(metrics_data).calculate
639
+ end
640
+
641
+ def count_facts(manager)
642
+ manager.global_store.facts.count
643
+ end
644
+
645
+ def count_entities(manager)
646
+ manager.global_store.entities.count
647
+ end
648
+ end
649
+
650
+ # ✅ Sandi Metz: Formatter has single responsibility
651
+ class StatisticsFormatter
652
+ def initialize(output)
653
+ @output = output
654
+ end
655
+
656
+ def format(statistics)
657
+ @output.puts "\nToken Economics:"
658
+ @output.puts " Input tokens: #{statistics.total_input_tokens}"
659
+ @output.puts " Output tokens: #{statistics.total_output_tokens}"
660
+ @output.puts " Facts extracted: #{statistics.total_facts}"
661
+ @output.puts " Efficiency: #{statistics.efficiency} facts/1k tokens"
662
+ @output.puts " Status: #{efficiency_status(statistics)}"
663
+ end
664
+
665
+ private
666
+
667
+ def efficiency_status(statistics)
668
+ statistics.efficient? ? "Good" : "Could be improved"
669
+ end
670
+ end
671
+ end
672
+ end
673
+ ```
674
+
675
+ **Expert Notes:**
676
+ - ✅ **Gary Bernhardt**: Pure calculator (no I/O), data passed in
677
+ - ✅ **Avdi Grimm**: Statistics result object with behavior (efficient?)
678
+ - ✅ **Sandi Metz**: Small classes with single responsibility
679
+ - ✅ **Kent Beck**: Calculator can be tested without database
680
+
681
+ **Tests:**
682
+ ```ruby
683
+ RSpec.describe ClaudeMemory::Domain::StatisticsCalculator do
684
+ describe "#calculate" do
685
+ let(:metrics_data) do
686
+ [
687
+ { input_tokens: 1000, output_tokens: 500, facts_extracted: 10 },
688
+ { input_tokens: 2000, output_tokens: 1000, facts_extracted: 15 }
689
+ ]
690
+ end
691
+
692
+ it "calculates totals correctly" do
693
+ calculator = described_class.new(metrics_data)
694
+ stats = calculator.calculate
695
+
696
+ expect(stats.total_input_tokens).to eq(3000)
697
+ expect(stats.total_output_tokens).to eq(1500)
698
+ expect(stats.total_facts).to eq(25)
699
+ end
700
+
701
+ it "calculates efficiency as facts per 1k tokens" do
702
+ calculator = described_class.new(metrics_data)
703
+ stats = calculator.calculate
704
+
705
+ # 25 facts / 3000 tokens * 1000 = 8.33
706
+ expect(stats.efficiency).to eq(8.33)
707
+ end
708
+
709
+ context "with no data" do
710
+ it "returns zero efficiency" do
711
+ calculator = described_class.new([])
712
+ stats = calculator.calculate
713
+
714
+ expect(stats.efficiency).to eq(0.0)
715
+ end
716
+ end
717
+ end
718
+ end
719
+
720
+ RSpec.describe ClaudeMemory::Domain::Statistics do
721
+ describe "#efficient?" do
722
+ it "returns true when efficiency > 5.0" do
723
+ stats = described_class.new(
724
+ total_input_tokens: 1000,
725
+ total_output_tokens: 500,
726
+ total_facts: 10,
727
+ efficiency: 10.0
728
+ )
729
+
730
+ expect(stats).to be_efficient
731
+ end
732
+
733
+ it "returns false when efficiency <= 5.0" do
734
+ stats = described_class.new(
735
+ total_input_tokens: 1000,
736
+ total_output_tokens: 500,
737
+ total_facts: 3,
738
+ efficiency: 3.0
739
+ )
740
+
741
+ expect(stats).not_to be_efficient
742
+ end
743
+ end
744
+ end
745
+ ```
746
+
747
+ **Time Estimate**: 30-35 minutes (includes result object)
748
+
749
+ ---
750
+
751
+ ### Pattern 5: Adding Command Line Flags
752
+
753
+ **Use Case**: --async, --verbose, --format options
754
+
755
+ **Recipe:**
756
+ ```ruby
757
+ # ✅ Sandi Metz: Small, focused methods
758
+ module ClaudeMemory
759
+ module Commands
760
+ class ConfigurableCommand < BaseCommand
761
+ def call(args)
762
+ opts = parse_options(args)
763
+ return 1 if opts.nil?
764
+
765
+ execute_with_options(opts)
766
+ end
767
+
768
+ private
769
+
770
+ def parse_options(args)
771
+ opts = default_options
772
+
773
+ OptionParser.new do |parser|
774
+ configure_parser(parser, opts)
775
+ end.parse!(args)
776
+
777
+ opts
778
+ rescue OptionParser::InvalidOption => e
779
+ stderr.puts "Error: #{e.message}"
780
+ nil
781
+ end
782
+
783
+ def configure_parser(parser, opts)
784
+ parser.banner = "Usage: claude-memory command [options]"
785
+ parser.on("--async", "Run in background") { opts[:async] = true }
786
+ parser.on("--verbose", "Verbose output") { opts[:verbose] = true }
787
+ parser.on("--format FORMAT", ["text", "json"], "Output format") do |f|
788
+ opts[:format] = f
789
+ end
790
+ end
791
+
792
+ def default_options
793
+ { async: false, verbose: false, format: "text" }
794
+ end
795
+
796
+ # ✅ Kent Beck: Method name reveals intent
797
+ def execute_with_options(opts)
798
+ if opts[:async]
799
+ execute_in_background(opts)
800
+ else
801
+ execute_synchronously(opts)
802
+ end
803
+ end
804
+
805
+ def execute_in_background(opts)
806
+ BackgroundExecutor.new(stdout).execute do
807
+ perform_work(opts)
808
+ end
809
+ 0
810
+ end
811
+
812
+ def execute_synchronously(opts)
813
+ result = perform_work(opts)
814
+ output_result(result, opts)
815
+ 0
816
+ end
817
+
818
+ def perform_work(opts)
819
+ # Actual work implementation
820
+ end
821
+
822
+ def output_result(result, opts)
823
+ # Output formatting
824
+ end
825
+ end
826
+
827
+ # ✅ Sandi Metz: Extract background execution to separate class
828
+ class BackgroundExecutor
829
+ def initialize(output)
830
+ @output = output
831
+ end
832
+
833
+ def execute(&block)
834
+ pid = Process.fork(&block)
835
+ Process.detach(pid)
836
+ @output.puts "Running in background (PID: #{pid})"
837
+ rescue NotImplementedError
838
+ # Windows doesn't support fork
839
+ @output.puts "Background execution not supported on this platform"
840
+ block.call
841
+ end
842
+ end
843
+ end
844
+ end
845
+ ```
846
+
847
+ **Expert Notes:**
848
+ - ✅ **Sandi Metz**: Small methods, background executor extracted
849
+ - ✅ **Kent Beck**: Clear method names (execute_in_background vs execute_synchronously)
850
+ - ✅ **Gary Bernhardt**: Separation of concerns
851
+
852
+ **Tests:**
853
+ ```ruby
854
+ RSpec.describe ClaudeMemory::Commands::BackgroundExecutor do
855
+ let(:output) { StringIO.new }
856
+ let(:executor) { described_class.new(output) }
857
+
858
+ describe "#execute" do
859
+ it "forks process and reports PID" do
860
+ allow(Process).to receive(:fork).and_yield.and_return(12345)
861
+ allow(Process).to receive(:detach)
862
+
863
+ work_done = false
864
+ executor.execute { work_done = true }
865
+
866
+ expect(work_done).to be true
867
+ expect(output.string).to include("PID: 12345")
868
+ expect(Process).to have_received(:detach).with(12345)
869
+ end
870
+
871
+ context "on Windows" do
872
+ it "executes synchronously when fork not available" do
873
+ allow(Process).to receive(:fork).and_raise(NotImplementedError)
874
+
875
+ work_done = false
876
+ executor.execute { work_done = true }
877
+
878
+ expect(work_done).to be true
879
+ expect(output.string).to include("not supported")
880
+ end
881
+ end
882
+ end
883
+ end
884
+ ```
885
+
886
+ **Time Estimate**: 20-25 minutes
887
+
888
+ ---
889
+
890
+ ### Pattern 6: Background Processing (Simple Fork)
891
+
892
+ **Use Case**: Non-blocking hook execution
893
+
894
+ **Recipe:**
895
+ ```ruby
896
+ # lib/claude_memory/commands/hook_ingest_command.rb
897
+ module ClaudeMemory
898
+ module Commands
899
+ class HookIngestCommand < BaseCommand
900
+ def call(args)
901
+ opts = parse_options(args)
902
+ return 1 if opts.nil?
903
+
904
+ payload = read_stdin
905
+
906
+ if opts[:async]
907
+ execute_async(payload, opts)
908
+ else
909
+ execute_sync(payload, opts)
910
+ end
911
+ end
912
+
913
+ private
914
+
915
+ def execute_async(payload, opts)
916
+ # ✅ Gary Bernhardt: Use Configuration for paths
917
+ log_path = LogPath.for_async_operation
918
+
919
+ BackgroundIngester.new(log_path, stdout).ingest(payload)
920
+ 0
921
+ rescue => e
922
+ stderr.puts "Failed to start background process: #{e.message}"
923
+ 1
924
+ end
925
+
926
+ def execute_sync(payload, opts)
927
+ result = perform_ingestion(payload)
928
+ stdout.puts "Ingested: #{result.facts_count} facts"
929
+ 0
930
+ end
931
+
932
+ def perform_ingestion(payload)
933
+ manager = Store::StoreManager.new
934
+ ingester = Ingest::Ingester.new(manager)
935
+ result = ingester.ingest(payload)
936
+ manager.close
937
+ result
938
+ end
939
+ end
940
+
941
+ # ✅ Sandi Metz: Extract background logic to separate class
942
+ class BackgroundIngester
943
+ def initialize(log_path, output)
944
+ @log_path = log_path
945
+ @output = output
946
+ end
947
+
948
+ def ingest(payload)
949
+ pid = fork_and_ingest(payload)
950
+ Process.detach(pid)
951
+ report_started(pid)
952
+ end
953
+
954
+ private
955
+
956
+ def fork_and_ingest(payload)
957
+ Process.fork do
958
+ redirect_output_to_log
959
+ perform_ingestion(payload)
960
+ exit 0
961
+ end
962
+ end
963
+
964
+ def redirect_output_to_log
965
+ $stdout.reopen(@log_path, "a")
966
+ $stderr.reopen(@log_path, "a")
967
+ end
968
+
969
+ def perform_ingestion(payload)
970
+ manager = Store::StoreManager.new
971
+ ingester = Ingest::Ingester.new(manager)
972
+ ingester.ingest(payload)
973
+ manager.close
974
+ end
975
+
976
+ def report_started(pid)
977
+ @output.puts "Ingestion started in background (PID: #{pid})"
978
+ @output.puts "Logs: #{@log_path}"
979
+ end
980
+ end
981
+
982
+ # ✅ Gary Bernhardt: Use Configuration for path resolution
983
+ class LogPath
984
+ def self.for_async_operation
985
+ if Configuration.project_dir
986
+ File.join(Configuration.project_dir, ".claude", "memory_ingest.log")
987
+ else
988
+ File.join(Configuration.home_dir, ".claude", "memory_ingest.log")
989
+ end
990
+ end
991
+ end
992
+ end
993
+ end
994
+ ```
995
+
996
+ **Expert Notes:**
997
+ - ✅ **Sandi Metz**: Background ingester is separate class with single responsibility
998
+ - ✅ **Gary Bernhardt**: Configuration used instead of direct ENV access
999
+ - ✅ **Kent Beck**: Small methods with clear purpose
1000
+ - ✅ **Avdi Grimm**: Confident code, no nil checks needed
1001
+
1002
+ **Tests:**
1003
+ ```ruby
1004
+ RSpec.describe ClaudeMemory::Commands::BackgroundIngester do
1005
+ let(:log_path) { "/tmp/test.log" }
1006
+ let(:output) { StringIO.new }
1007
+ let(:ingester) { described_class.new(log_path, output) }
1008
+
1009
+ describe "#ingest" do
1010
+ let(:payload) { { transcript_delta: "test content" } }
1011
+
1012
+ it "forks process and detaches" do
1013
+ allow(Process).to receive(:fork).and_yield.and_return(99999)
1014
+ allow(Process).to receive(:detach)
1015
+
1016
+ # Mock the actual ingestion
1017
+ allow_any_instance_of(ClaudeMemory::Ingest::Ingester)
1018
+ .to receive(:ingest)
1019
+ .and_return(double(facts_count: 5))
1020
+
1021
+ ingester.ingest(payload)
1022
+
1023
+ expect(Process).to have_received(:fork)
1024
+ expect(Process).to have_received(:detach).with(99999)
1025
+ expect(output.string).to include("PID: 99999")
1026
+ expect(output.string).to include("Logs: #{log_path}")
1027
+ end
1028
+ end
1029
+ end
1030
+
1031
+ RSpec.describe ClaudeMemory::Commands::LogPath do
1032
+ describe ".for_async_operation" do
1033
+ context "in project directory" do
1034
+ before do
1035
+ allow(ClaudeMemory::Configuration).to receive(:project_dir)
1036
+ .and_return("/path/to/project")
1037
+ end
1038
+
1039
+ it "returns project log path" do
1040
+ path = described_class.for_async_operation
1041
+ expect(path).to eq("/path/to/project/.claude/memory_ingest.log")
1042
+ end
1043
+ end
1044
+
1045
+ context "outside project directory" do
1046
+ before do
1047
+ allow(ClaudeMemory::Configuration).to receive(:project_dir).and_return(nil)
1048
+ allow(ClaudeMemory::Configuration).to receive(:home_dir).and_return("/home/user")
1049
+ end
1050
+
1051
+ it "returns home directory log path" do
1052
+ path = described_class.for_async_operation
1053
+ expect(path).to eq("/home/user/.claude/memory_ingest.log")
1054
+ end
1055
+ end
1056
+ end
1057
+ end
1058
+ ```
1059
+
1060
+ **Important Notes:**
1061
+ - Test fork behavior with mocks
1062
+ - Windows doesn't support fork (consider fallback)
1063
+ - Always detach process to avoid zombies
1064
+ - Use Configuration for path resolution
1065
+
1066
+ **Time Estimate**: 45-60 minutes
1067
+
1068
+ ---
1069
+
1070
+ ## Expert Principles Applied
1071
+
1072
+ ### Sandi Metz (POODR)
1073
+ - ✅ Small methods (< 5 lines ideal)
1074
+ - ✅ Single responsibility per class
1075
+ - ✅ Parameter objects for long parameter lists
1076
+ - ✅ Extract formatters, calculators, executors
1077
+
1078
+ ### Jeremy Evans (Sequel)
1079
+ - ✅ DateTime columns instead of String timestamps
1080
+ - ✅ Proper Sequel dataset usage
1081
+ - ✅ Transaction safety where needed
1082
+
1083
+ ### Kent Beck (TDD, Simple Design)
1084
+ - ✅ Method names reveal intent
1085
+ - ✅ Testable design (dependency injection)
1086
+ - ✅ Test edge cases (zero values, errors)
1087
+
1088
+ ### Avdi Grimm (Confident Ruby)
1089
+ - ✅ Result objects instead of hashes
1090
+ - ✅ No nil checks (use null objects if needed)
1091
+ - ✅ Duck typing (accept object or hash)
1092
+ - ✅ Immutable value objects (frozen)
1093
+
1094
+ ### Gary Bernhardt (Boundaries)
1095
+ - ✅ Pure calculators (no I/O in logic)
1096
+ - ✅ Dependency injection for testability
1097
+ - ✅ Configuration class for ENV access
1098
+ - ✅ Ensure resource cleanup (ensure blocks)
1099
+
1100
+ ---
1101
+
1102
+ ## Feature Complexity Assessment
1103
+
1104
+ ### Quick Assessment Checklist
1105
+
1106
+ **Low Complexity** (15-30 min):
1107
+ - [ ] Pure Ruby, no external dependencies
1108
+ - [ ] Clear, well-defined requirements
1109
+ - [ ] Existing patterns to follow
1110
+ - [ ] Straightforward testing
1111
+
1112
+ **Medium Complexity** (30-60 min):
1113
+ - [ ] Requires new gem dependency
1114
+ - [ ] Some architectural decisions needed
1115
+ - [ ] Background processing (simple fork)
1116
+ - [ ] Multiple files affected
1117
+
1118
+ **High Complexity** (60+ min or skip):
1119
+ - [ ] External services required
1120
+ - [ ] Daemon/worker management
1121
+ - [ ] Web UI components
1122
+ - [ ] Cross-platform compatibility issues
1123
+ - [ ] Security-critical code
1124
+
1125
+ ### Common Pitfalls & Solutions
1126
+
1127
+ 1. **String Timestamps**
1128
+ - ❌ Problem: `String :created_at`
1129
+ - ✅ Solution: `DateTime :created_at`
1130
+
1131
+ 2. **Direct ENV Access**
1132
+ - ❌ Problem: `ENV["CLAUDE_PROJECT_DIR"]`
1133
+ - ✅ Solution: `Configuration.project_dir`
1134
+
1135
+ 3. **Long Parameter Lists**
1136
+ - ❌ Problem: 7+ parameters
1137
+ - ✅ Solution: Parameter object
1138
+
1139
+ 4. **Creating Dependencies in Methods**
1140
+ - ❌ Problem: `store = Store.new` inside method
1141
+ - ✅ Solution: Inject dependency
1142
+
1143
+ 5. **No Resource Cleanup**
1144
+ - ❌ Problem: `manager.close` can be skipped
1145
+ - ✅ Solution: `ensure` block
1146
+
1147
+ 6. **Nil Checks Everywhere**
1148
+ - ❌ Problem: `return nil unless x`
1149
+ - ✅ Solution: Result objects or null objects
1150
+
1151
+ 7. **Mixed I/O and Logic**
1152
+ - ❌ Problem: Database queries in calculator
1153
+ - ✅ Solution: Pass data to pure calculator
1154
+
1155
+ 8. **Vague Method Names**
1156
+ - ❌ Problem: `do_something`, `process`
1157
+ - ✅ Solution: `calculate_efficiency`, `execute_in_background`
1158
+
1159
+ ---
1160
+
1161
+ ## When to Split into Multiple Commits
1162
+
1163
+ **Split when:**
1164
+ - Schema change + feature implementation (2 commits)
1165
+ - Core feature + CLI command (2 commits)
1166
+ - Multiple independent enhancements (separate commits)
1167
+
1168
+ **Keep together when:**
1169
+ - Feature + tests (same commit)
1170
+ - Command + help text (same commit)
1171
+ - Implementation + error handling (same commit)
1172
+ - Parameter object + method using it (same commit)
1173
+
1174
+ ---
1175
+
1176
+ ## Testing Strategies
1177
+
1178
+ ### Pure Calculators (Fast)
1179
+ ```ruby
1180
+ # No mocks needed - pure logic
1181
+ it "calculates efficiency" do
1182
+ calculator = StatisticsCalculator.new(data)
1183
+ expect(calculator.calculate.efficiency).to eq(10.0)
1184
+ end
1185
+ ```
1186
+
1187
+ ### Commands (With Dependency Injection)
1188
+ ```ruby
1189
+ # Inject test doubles
1190
+ let(:store_manager) { instance_double(Store::StoreManager) }
1191
+ let(:command) { described_class.new(store_manager: store_manager) }
1192
+
1193
+ it "uses injected store manager" do
1194
+ command.call([])
1195
+ expect(store_manager).to have_received(:global_store)
1196
+ end
1197
+ ```
1198
+
1199
+ ### Resource Cleanup
1200
+ ```ruby
1201
+ it "closes manager even on exception" do
1202
+ allow(store).to receive(:facts).and_raise("DB error")
1203
+
1204
+ expect { command.call([]) }.to raise_error("DB error")
1205
+ expect(manager).to have_received(:close)
1206
+ end
1207
+ ```
1208
+
1209
+ ### Result Objects
1210
+ ```ruby
1211
+ it "returns statistics object with behavior" do
1212
+ stats = calculator.calculate
1213
+
1214
+ expect(stats).to respond_to(:efficient?)
1215
+ expect(stats.efficient?).to be true
1216
+ end
1217
+ ```
1218
+
1219
+ ---
1220
+
1221
+ **Remember:** These patterns demonstrate best practices from all five experts. Use them as templates when implementing new features with `/improve`.