appydave-tools 0.70.0 → 0.71.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/brainstorming-agent.md +227 -0
  3. data/.claude/commands/cli-test.md +251 -0
  4. data/.claude/commands/dev.md +234 -0
  5. data/.claude/commands/po.md +227 -0
  6. data/.claude/commands/progress.md +51 -0
  7. data/.claude/commands/uat.md +321 -0
  8. data/.rubocop.yml +9 -0
  9. data/AGENTS.md +43 -0
  10. data/CHANGELOG.md +12 -0
  11. data/CLAUDE.md +26 -3
  12. data/README.md +15 -0
  13. data/bin/dam +21 -1
  14. data/bin/jump.rb +29 -0
  15. data/bin/subtitle_processor.rb +54 -1
  16. data/bin/zsh_history.rb +846 -0
  17. data/docs/README.md +162 -69
  18. data/docs/architecture/cli/exe-bin-convention.md +434 -0
  19. data/docs/architecture/cli-patterns.md +631 -0
  20. data/docs/architecture/gpt-context/gpt-context-architecture.md +325 -0
  21. data/docs/architecture/gpt-context/gpt-context-implementation-guide.md +419 -0
  22. data/docs/architecture/gpt-context/gpt-context-vision.md +179 -0
  23. data/docs/architecture/testing/testing-patterns.md +762 -0
  24. data/docs/backlog.md +120 -0
  25. data/docs/cli-tests/FR-3-jump-location-tool.md +515 -0
  26. data/docs/specs/fr-002-gpt-context-help-system.md +265 -0
  27. data/docs/specs/fr-003-jump-location-tool.md +779 -0
  28. data/docs/specs/zsh-history-tool.md +820 -0
  29. data/docs/uat/FR-3-jump-location-tool.md +741 -0
  30. data/exe/jump +11 -0
  31. data/exe/{subtitle_manager → subtitle_processor} +1 -1
  32. data/exe/zsh_history +11 -0
  33. data/lib/appydave/tools/configuration/openai.rb +1 -1
  34. data/lib/appydave/tools/dam/file_helper.rb +28 -0
  35. data/lib/appydave/tools/dam/project_listing.rb +4 -30
  36. data/lib/appydave/tools/dam/s3_operations.rb +2 -1
  37. data/lib/appydave/tools/dam/ssd_status.rb +226 -0
  38. data/lib/appydave/tools/dam/status.rb +3 -51
  39. data/lib/appydave/tools/jump/cli.rb +561 -0
  40. data/lib/appydave/tools/jump/commands/add.rb +52 -0
  41. data/lib/appydave/tools/jump/commands/base.rb +43 -0
  42. data/lib/appydave/tools/jump/commands/generate.rb +153 -0
  43. data/lib/appydave/tools/jump/commands/remove.rb +58 -0
  44. data/lib/appydave/tools/jump/commands/report.rb +214 -0
  45. data/lib/appydave/tools/jump/commands/update.rb +42 -0
  46. data/lib/appydave/tools/jump/commands/validate.rb +54 -0
  47. data/lib/appydave/tools/jump/config.rb +233 -0
  48. data/lib/appydave/tools/jump/formatters/base.rb +48 -0
  49. data/lib/appydave/tools/jump/formatters/json_formatter.rb +19 -0
  50. data/lib/appydave/tools/jump/formatters/paths_formatter.rb +21 -0
  51. data/lib/appydave/tools/jump/formatters/table_formatter.rb +183 -0
  52. data/lib/appydave/tools/jump/location.rb +134 -0
  53. data/lib/appydave/tools/jump/path_validator.rb +47 -0
  54. data/lib/appydave/tools/jump/search.rb +230 -0
  55. data/lib/appydave/tools/subtitle_processor/transcript.rb +51 -0
  56. data/lib/appydave/tools/version.rb +1 -1
  57. data/lib/appydave/tools/zsh_history/command.rb +37 -0
  58. data/lib/appydave/tools/zsh_history/config.rb +235 -0
  59. data/lib/appydave/tools/zsh_history/filter.rb +184 -0
  60. data/lib/appydave/tools/zsh_history/formatter.rb +75 -0
  61. data/lib/appydave/tools/zsh_history/parser.rb +101 -0
  62. data/lib/appydave/tools.rb +25 -0
  63. data/package.json +1 -1
  64. metadata +51 -4
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Jump
6
+ # Search provides fuzzy search across location metadata
7
+ #
8
+ # Scoring algorithm based on match type:
9
+ # - Exact key match: 100 points
10
+ # - Key contains term: 50 points
11
+ # - Brand/client alias match: 40 points
12
+ # - Tag match: 30 points
13
+ # - Type match: 20 points
14
+ # - Description contains: 10 points
15
+ # - Path contains: 5 points
16
+ #
17
+ # @example Basic search
18
+ # search = Search.new(config)
19
+ # results = search.search('appydave ruby')
20
+ # results[:success] # => true
21
+ # results[:results] # => Array of scored location hashes
22
+ class Search
23
+ SCORE_EXACT_KEY = 100
24
+ SCORE_KEY_CONTAINS = 50
25
+ SCORE_BRAND_CLIENT_ALIAS = 40
26
+ SCORE_TAG_MATCH = 30
27
+ SCORE_TYPE_MATCH = 20
28
+ SCORE_DESCRIPTION_CONTAINS = 10
29
+ SCORE_PATH_CONTAINS = 5
30
+
31
+ attr_reader :config
32
+
33
+ def initialize(config)
34
+ @config = config
35
+ end
36
+
37
+ # Search for locations matching query terms
38
+ #
39
+ # @param query [String] Space-separated search terms
40
+ # @return [Hash] Search results with success, count, and results
41
+ def search(query)
42
+ terms = parse_query(query)
43
+
44
+ return empty_result if terms.empty?
45
+
46
+ scored_locations = config.locations.map do |location|
47
+ score = calculate_score(location, terms)
48
+ next if score.zero?
49
+
50
+ location_to_result(location, score)
51
+ end.compact
52
+
53
+ # Sort by score descending, then by key alphabetically
54
+ sorted = scored_locations.sort_by { |r| [-r[:score], r[:key]] }
55
+
56
+ # Add index numbers
57
+ sorted.each_with_index { |result, i| result[:index] = i + 1 }
58
+
59
+ {
60
+ success: true,
61
+ count: sorted.size,
62
+ results: sorted
63
+ }
64
+ end
65
+
66
+ # Get a location by exact key
67
+ #
68
+ # @param key [String] Exact key to find
69
+ # @return [Hash] Result with success and result/error
70
+ def get(key)
71
+ location = config.find(key)
72
+
73
+ if location
74
+ {
75
+ success: true,
76
+ result: location_to_result(location, SCORE_EXACT_KEY)
77
+ }
78
+ else
79
+ suggestions = find_suggestions(key)
80
+ {
81
+ success: false,
82
+ error: 'Location not found',
83
+ code: 'NOT_FOUND',
84
+ suggestion: suggestions.empty? ? nil : "Did you mean: #{suggestions.join(', ')}?"
85
+ }
86
+ end
87
+ end
88
+
89
+ # List all locations
90
+ #
91
+ # @return [Hash] All locations with success and count
92
+ def list
93
+ results = config.locations.map.with_index(1) do |location, index|
94
+ result = location_to_result(location, 0)
95
+ result[:index] = index
96
+ result
97
+ end
98
+
99
+ {
100
+ success: true,
101
+ count: results.size,
102
+ results: results
103
+ }
104
+ end
105
+
106
+ private
107
+
108
+ def parse_query(query)
109
+ return [] if query.nil? || query.strip.empty?
110
+
111
+ query.downcase.split(/\s+/).reject(&:empty?)
112
+ end
113
+
114
+ def empty_result
115
+ {
116
+ success: true,
117
+ count: 0,
118
+ results: []
119
+ }
120
+ end
121
+
122
+ def calculate_score(location, terms)
123
+ total_score = 0
124
+
125
+ terms.each do |term|
126
+ total_score += score_for_term(location, term)
127
+ end
128
+
129
+ total_score
130
+ end
131
+
132
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
133
+ def score_for_term(location, term)
134
+ score = 0
135
+
136
+ # Exact key match
137
+ if location.key == term
138
+ score += SCORE_EXACT_KEY
139
+ elsif location.key&.include?(term)
140
+ score += SCORE_KEY_CONTAINS
141
+ end
142
+
143
+ # Brand/client alias match
144
+ score += SCORE_BRAND_CLIENT_ALIAS if brand_alias_match?(location.brand, term)
145
+ score += SCORE_BRAND_CLIENT_ALIAS if client_alias_match?(location.client, term)
146
+
147
+ # Tag match
148
+ score += SCORE_TAG_MATCH if location.tags.any? { |tag| tag.downcase == term }
149
+
150
+ # Type match
151
+ score += SCORE_TYPE_MATCH if location.type&.downcase == term
152
+
153
+ # Description contains
154
+ score += SCORE_DESCRIPTION_CONTAINS if location.description&.downcase&.include?(term)
155
+
156
+ # Path contains
157
+ score += SCORE_PATH_CONTAINS if location.path&.downcase&.include?(term)
158
+
159
+ score
160
+ end
161
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
162
+
163
+ def brand_alias_match?(brand_key, term)
164
+ return false unless brand_key
165
+
166
+ return true if brand_key.downcase == term
167
+
168
+ brand_info = config.brands[brand_key]
169
+ return false unless brand_info
170
+
171
+ aliases = brand_info['aliases'] || brand_info[:aliases] || []
172
+ aliases.any? { |a| a.downcase == term }
173
+ end
174
+
175
+ def client_alias_match?(client_key, term)
176
+ return false unless client_key
177
+
178
+ return true if client_key.downcase == term
179
+
180
+ client_info = config.clients[client_key]
181
+ return false unless client_info
182
+
183
+ aliases = client_info['aliases'] || client_info[:aliases] || []
184
+ aliases.any? { |a| a.downcase == term }
185
+ end
186
+
187
+ def location_to_result(location, score)
188
+ {
189
+ key: location.key,
190
+ path: expand_path(location.path),
191
+ jump: location.jump,
192
+ brand: location.brand,
193
+ client: location.client,
194
+ type: location.type,
195
+ tags: location.tags,
196
+ description: location.description,
197
+ score: score
198
+ }.compact
199
+ end
200
+
201
+ def expand_path(path)
202
+ return path unless path
203
+
204
+ File.expand_path(path)
205
+ end
206
+
207
+ def find_suggestions(key)
208
+ return [] unless key
209
+
210
+ # Find keys that are similar (contain or start with same letters)
211
+ config.locations
212
+ .map(&:key)
213
+ .select { |k| similar?(k, key) }
214
+ .first(3)
215
+ end
216
+
217
+ def similar?(candidate, query)
218
+ # Simple similarity: shares first 2 chars or contains query
219
+ return true if candidate.start_with?(query[0..1])
220
+ return true if candidate.include?(query)
221
+ return true if query.include?(candidate)
222
+
223
+ # Levenshtein-like: at least 50% chars in common
224
+ common = (candidate.chars & query.chars).size
225
+ common >= [candidate.length, query.length].min * 0.5
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module SubtitleProcessor
6
+ # Convert SRT to plain text transcript
7
+ # Strips timestamps and indices, keeping only the spoken text
8
+ class Transcript
9
+ attr_reader :content
10
+
11
+ def initialize(file_path: nil, srt_content: nil)
12
+ if file_path && srt_content
13
+ raise ArgumentError, 'You cannot provide both a file path and an SRT content stream.'
14
+ elsif file_path.nil? && srt_content.nil?
15
+ raise ArgumentError, 'You must provide either a file path or an SRT content stream.'
16
+ end
17
+
18
+ @content = if file_path
19
+ File.read(file_path, encoding: 'UTF-8')
20
+ else
21
+ srt_content
22
+ end
23
+ end
24
+
25
+ # Convert SRT to plain text transcript
26
+ # @param paragraph_gap [Integer] Number of newlines between subtitle blocks (default: 1 = single newline)
27
+ # @return [String] Plain text transcript
28
+ def extract(paragraph_gap: 1)
29
+ parser = Join::SRTParser.new
30
+ subtitles = parser.parse(@content)
31
+
32
+ separator = "\n" * paragraph_gap
33
+ subtitles.map(&:text).join(separator)
34
+ end
35
+
36
+ # Write transcript to file
37
+ # @param output_file [String] Path to output file
38
+ # @param paragraph_gap [Integer] Number of newlines between subtitle blocks
39
+ def write(output_file, paragraph_gap: 1)
40
+ transcript = extract(paragraph_gap: paragraph_gap)
41
+ File.write(output_file, transcript, encoding: 'UTF-8')
42
+ puts "Transcript written to #{output_file}"
43
+ rescue Errno::EACCES
44
+ puts "Permission denied: Unable to write to #{output_file}"
45
+ rescue StandardError => e
46
+ puts "An error occurred while writing to the file: #{e.message}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Appydave
4
4
  module Tools
5
- VERSION = '0.70.0'
5
+ VERSION = '0.71.0'
6
6
  end
7
7
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module ZshHistory
6
+ # Represents a single command from ZSH history
7
+ Command = Struct.new(
8
+ :timestamp, # Integer - Unix timestamp from history
9
+ :datetime, # Time - Parsed datetime
10
+ :text, # String - Full command text (multi-line joined)
11
+ :is_multiline, # Boolean - Was this a continuation command?
12
+ :category, # Symbol - :wanted, :unwanted, :unsure
13
+ :raw_lines, # Array<String> - Original lines from file
14
+ :matched_pattern, # String - Pattern that matched (for verbose output)
15
+ keyword_init: true
16
+ ) do
17
+ def formatted_datetime(format = '%Y-%m-%d %H:%M:%S')
18
+ datetime&.strftime(format) || 'unknown'
19
+ end
20
+
21
+ def to_history_format
22
+ # Reconstruct in ZSH history format for writing back
23
+ raw_lines.join("\n")
24
+ end
25
+ end
26
+
27
+ # Result of filtering operations
28
+ FilterResult = Struct.new(
29
+ :wanted, # Array<Command>
30
+ :unwanted, # Array<Command>
31
+ :unsure, # Array<Command>
32
+ :stats, # Hash - { total:, wanted:, unwanted:, unsure: }
33
+ keyword_init: true
34
+ )
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module ZshHistory
6
+ # Loads zsh_history configuration from ~/.config/appydave/zsh_history/
7
+ #
8
+ # Directory structure:
9
+ # ~/.config/appydave/zsh_history/
10
+ # config.txt # default_profile=crash-recovery
11
+ # base_exclude.txt # Always excluded (typos, output lines)
12
+ # profiles/
13
+ # crash-recovery/
14
+ # exclude.txt # Profile-specific excludes
15
+ # include.txt # Profile-specific includes
16
+ #
17
+ # Pattern files: One regex pattern per line, # for comments, blank lines ignored
18
+ #
19
+ class Config
20
+ DEFAULT_CONFIG_PATH = File.expand_path('~/.config/appydave/zsh_history')
21
+
22
+ attr_reader :config_path, :profile_name
23
+
24
+ def initialize(config_path: nil, profile: nil)
25
+ @config_path = config_path || DEFAULT_CONFIG_PATH
26
+ @profile_name = profile || default_profile
27
+ end
28
+
29
+ # Returns merged exclude patterns (base + profile)
30
+ def exclude_patterns
31
+ patterns = load_base_exclude
32
+ patterns += load_profile_patterns('exclude') if profile_name
33
+ patterns.empty? ? nil : patterns
34
+ end
35
+
36
+ # Returns profile include patterns
37
+ def include_patterns
38
+ patterns = load_profile_patterns('include')
39
+ patterns.empty? ? nil : patterns
40
+ end
41
+
42
+ # Check if config directory exists
43
+ def configured?
44
+ Dir.exist?(config_path)
45
+ end
46
+
47
+ # Check if specific profile exists
48
+ def profile_exists?(name = profile_name)
49
+ return false unless name
50
+
51
+ profile_path = File.join(config_path, 'profiles', name)
52
+ Dir.exist?(profile_path)
53
+ end
54
+
55
+ # List available profiles
56
+ def available_profiles
57
+ profiles_dir = File.join(config_path, 'profiles')
58
+ return [] unless Dir.exist?(profiles_dir)
59
+
60
+ Dir.children(profiles_dir)
61
+ .select { |f| File.directory?(File.join(profiles_dir, f)) }
62
+ .sort
63
+ end
64
+
65
+ # Get default profile from config.txt
66
+ def default_profile
67
+ config_file = File.join(config_path, 'config.txt')
68
+ return nil unless File.exist?(config_file)
69
+
70
+ File.readlines(config_file).each do |line|
71
+ line = line.strip
72
+ next if line.empty? || line.start_with?('#')
73
+
74
+ key, value = line.split('=', 2)
75
+ return value.strip if key.strip == 'default_profile'
76
+ end
77
+
78
+ nil
79
+ end
80
+
81
+ # Create default config structure with example files
82
+ def self.create_default_config(config_path = DEFAULT_CONFIG_PATH)
83
+ FileUtils.mkdir_p(config_path)
84
+ FileUtils.mkdir_p(File.join(config_path, 'profiles', 'crash-recovery'))
85
+
86
+ # Create config.txt
87
+ write_if_missing(File.join(config_path, 'config.txt'), <<~CONFIG)
88
+ # ZSH History Configuration
89
+ # Set default profile (used when --profile not specified)
90
+ default_profile=crash-recovery
91
+ CONFIG
92
+
93
+ # Create base_exclude.txt
94
+ write_if_missing(File.join(config_path, 'base_exclude.txt'), <<~PATTERNS)
95
+ # Base exclude patterns - always applied
96
+ # These are noise in ANY scenario
97
+
98
+ # Typos and single-letter commands
99
+ ^[a-z]$
100
+ ^[a-z]{2}$
101
+
102
+ # Output lines accidentally captured
103
+ ^\\[\\d+\\]
104
+ ^zsh: command not found
105
+ ^X Process completed
106
+ ^Coverage report
107
+ ^Line Coverage:
108
+ ^Finished in \\d
109
+ ^\\d+ examples, \\d+ failures
110
+
111
+ # Process listing output
112
+ ^davidcruwys\\s+\\d+
113
+ PATTERNS
114
+
115
+ # Create crash-recovery profile
116
+ profile_path = File.join(config_path, 'profiles', 'crash-recovery')
117
+
118
+ write_if_missing(File.join(profile_path, 'exclude.txt'), <<~PATTERNS)
119
+ # Crash Recovery - Exclude patterns
120
+ # Navigation and quick-check commands (noise when finding what you were working on)
121
+
122
+ # Basic navigation
123
+ ^ls$
124
+ ^ls -
125
+ ^pwd$
126
+ ^clear$
127
+ ^exit$
128
+ ^x$
129
+ ^cd$
130
+ ^cd -$
131
+ ^cd \\.\\.
132
+ ^\\.\\.
133
+
134
+ # Git quick checks (not actual work)
135
+ ^git status$
136
+ ^git diff$
137
+ ^git log$
138
+ ^git pull$
139
+ ^gs$
140
+ ^gd$
141
+ ^gl$
142
+
143
+ # History and lookups
144
+ ^h$
145
+ ^history
146
+ ^which
147
+ ^type
148
+
149
+ # File viewing
150
+ ^cat
151
+ ^head
152
+ ^tail
153
+ ^echo \\$
154
+ PATTERNS
155
+
156
+ write_if_missing(File.join(profile_path, 'include.txt'), <<~PATTERNS)
157
+ # Crash Recovery - Include patterns
158
+ # Commands that represent actual work
159
+
160
+ # Jump aliases (navigation to projects)
161
+ ^j[a-z]
162
+
163
+ # Tools
164
+ ^dam
165
+ ^vat
166
+ ^claude
167
+ ^c-sonnet
168
+
169
+ # JavaScript/Node
170
+ ^bun run
171
+ ^bun dev$
172
+ ^bun web:
173
+ ^bun worker:
174
+ ^bun convex:
175
+ ^npm run
176
+
177
+ # Ruby
178
+ ^rake
179
+ ^bundle
180
+
181
+ # Git commits (actual work, not checks)
182
+ ^git commit
183
+ ^git push
184
+ ^git add
185
+ ^gac
186
+ ^kfeat
187
+ ^kfix
188
+
189
+ # Docker
190
+ ^docker
191
+ ^docker-compose
192
+
193
+ # Package installation
194
+ ^brew install
195
+ ^gem install
196
+ ^npm install
197
+ PATTERNS
198
+
199
+ config_path
200
+ end
201
+
202
+ class << self
203
+ private
204
+
205
+ def write_if_missing(path, content)
206
+ return if File.exist?(path)
207
+
208
+ File.write(path, content)
209
+ end
210
+ end
211
+
212
+ private
213
+
214
+ def load_base_exclude
215
+ load_patterns_file(File.join(config_path, 'base_exclude.txt'))
216
+ end
217
+
218
+ def load_profile_patterns(type)
219
+ return [] unless profile_name
220
+
221
+ file_path = File.join(config_path, 'profiles', profile_name, "#{type}.txt")
222
+ load_patterns_file(file_path)
223
+ end
224
+
225
+ def load_patterns_file(file_path)
226
+ return [] unless File.exist?(file_path)
227
+
228
+ File.readlines(file_path)
229
+ .map(&:strip)
230
+ .reject { |line| line.empty? || line.start_with?('#') }
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end