ace-support-nav 0.25.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/config.yml +33 -0
  3. data/.ace-defaults/nav/protocols/guide-sources/ace-support-nav.yml +7 -0
  4. data/.ace-defaults/nav/protocols/guide.yml +69 -0
  5. data/.ace-defaults/nav/protocols/prompt.yml +39 -0
  6. data/.ace-defaults/nav/protocols/skill-sources/ace-support-nav.yml +19 -0
  7. data/.ace-defaults/nav/protocols/skill.yml +22 -0
  8. data/.ace-defaults/nav/protocols/tmpl-sources/ace-support-nav.yml +7 -0
  9. data/.ace-defaults/nav/protocols/tmpl.yml +55 -0
  10. data/.ace-defaults/nav/protocols/wfi-sources/ace-support-nav.yml +7 -0
  11. data/.ace-defaults/nav/protocols/wfi.yml +61 -0
  12. data/CHANGELOG.md +231 -0
  13. data/LICENSE +21 -0
  14. data/README.md +48 -0
  15. data/Rakefile +12 -0
  16. data/docs/demo/ace-support-nav-getting-started.gif +0 -0
  17. data/docs/demo/ace-support-nav-getting-started.tape.yml +28 -0
  18. data/exe/ace-nav +49 -0
  19. data/handbook/workflow-instructions/test.wfi.md +14 -0
  20. data/lib/ace/support/nav/atoms/extension_inferrer.rb +134 -0
  21. data/lib/ace/support/nav/atoms/gem_resolver.rb +59 -0
  22. data/lib/ace/support/nav/atoms/path_normalizer.rb +52 -0
  23. data/lib/ace/support/nav/atoms/uri_parser.rb +62 -0
  24. data/lib/ace/support/nav/cli/commands/create.rb +114 -0
  25. data/lib/ace/support/nav/cli/commands/list.rb +122 -0
  26. data/lib/ace/support/nav/cli/commands/resolve.rb +187 -0
  27. data/lib/ace/support/nav/cli/commands/sources.rb +112 -0
  28. data/lib/ace/support/nav/cli.rb +66 -0
  29. data/lib/ace/support/nav/models/handbook_source.rb +73 -0
  30. data/lib/ace/support/nav/models/protocol_source.rb +104 -0
  31. data/lib/ace/support/nav/models/resource.rb +46 -0
  32. data/lib/ace/support/nav/models/resource_uri.rb +78 -0
  33. data/lib/ace/support/nav/molecules/config_loader.rb +275 -0
  34. data/lib/ace/support/nav/molecules/handbook_scanner.rb +204 -0
  35. data/lib/ace/support/nav/molecules/protocol_scanner.rb +434 -0
  36. data/lib/ace/support/nav/molecules/resource_resolver.rb +134 -0
  37. data/lib/ace/support/nav/molecules/source_registry.rb +133 -0
  38. data/lib/ace/support/nav/organisms/command_delegator.rb +122 -0
  39. data/lib/ace/support/nav/organisms/navigation_engine.rb +180 -0
  40. data/lib/ace/support/nav/version.rb +9 -0
  41. data/lib/ace/support/nav.rb +104 -0
  42. metadata +228 -0
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+ require_relative "source_registry"
6
+ require "ace/support/fs"
7
+ require "ace/support/config"
8
+
9
+ module Ace
10
+ module Support
11
+ module Nav
12
+ module Molecules
13
+ # Loads configuration from .ace/nav/*.yml files and protocols
14
+ # ADR-022: Uses Ace::Support::Config.create() for gem defaults + user cascade
15
+ class ConfigLoader
16
+ class Error < StandardError; end
17
+
18
+ def initialize(config_dir = nil, source_registry: nil)
19
+ @config_dir = find_config_dir(config_dir)
20
+ @configs = {}
21
+ @protocols = nil # Lazy load protocols
22
+ @source_registry = source_registry || SourceRegistry.new
23
+ end
24
+
25
+ # Load main settings configuration
26
+ # ADR-022: Uses Ace::Support::Config.create() for gem defaults + user cascade
27
+ #
28
+ # NOTE: The "nav" namespace is intentionally preserved (not "support/nav") for
29
+ # backward compatibility with existing user configurations in .ace/nav/config.yml.
30
+ # This allows users upgrading from ace-nav to ace-support-nav to keep their config
31
+ # without migration. See PR #152 review discussion for context.
32
+ #
33
+ # @return [Hash] Configuration with defaults and user overrides
34
+ def load_settings
35
+ # Return cached if already loaded
36
+ return @configs["settings"] if @configs.key?("settings")
37
+
38
+ # Load gem defaults via Ace::Support::Config
39
+ # Use centralized gem_root from Nav module (avoids path depth duplication)
40
+ resolver = Ace::Support::Config.create(
41
+ config_dir: ".ace",
42
+ defaults_dir: ".ace-defaults",
43
+ gem_path: Ace::Support::Nav.gem_root
44
+ )
45
+
46
+ # Get gem defaults first - uses "nav" namespace for backward compatibility
47
+ gem_defaults = begin
48
+ resolver.resolve_namespace("nav").data
49
+ rescue Ace::Support::Config::YamlParseError => e
50
+ warn "Warning: Failed to parse nav config: #{e.message}" if debug?
51
+ load_gem_defaults_only
52
+ rescue => e
53
+ warn "Warning: Could not load ace-support-nav config: #{e.message}" if debug?
54
+ load_gem_defaults_only
55
+ end
56
+
57
+ # Load user config from @config_dir if explicitly set (for testing/override)
58
+ # or use the cascade from resolver
59
+ user_config = if @config_dir
60
+ load_user_config_from_dir(@config_dir)
61
+ else
62
+ # No explicit config_dir - defaults already include cascade
63
+ {}
64
+ end
65
+
66
+ # Deep merge: user config over gem defaults
67
+ @configs["settings"] = Ace::Support::Config::Atoms::DeepMerger.merge(gem_defaults, user_config)
68
+ end
69
+
70
+ # Load protocol-specific configuration
71
+ def load_protocol_config(protocol)
72
+ protocols = load_protocols
73
+ return protocols[protocol] if protocols[protocol]
74
+
75
+ # Fallback to defaults for backward compatibility
76
+ default_protocol_config(protocol)
77
+ end
78
+
79
+ # Load all available protocols
80
+ def load_protocols
81
+ return @protocols if @protocols
82
+
83
+ @protocols = {}
84
+
85
+ # 0. Load from gem defaults (lowest priority)
86
+ gem_defaults_dir = File.join(Ace::Support::Nav.gem_root, ".ace-defaults", "nav", "protocols")
87
+ load_directory_protocols(gem_defaults_dir).each do |protocol_data|
88
+ key = protocol_data["protocol"]
89
+ @protocols[key] = protocol_data if key
90
+ end
91
+
92
+ # 1. Load from user ~/.ace/nav/protocols/
93
+ load_directory_protocols(File.expand_path("~/.ace/nav/protocols")).each do |protocol_data|
94
+ key = protocol_data["protocol"]
95
+ @protocols[key] = protocol_data if key
96
+ end
97
+
98
+ # 2. Load from project .ace/protocols/ hierarchy (highest priority)
99
+ # Find all .ace/protocols directories from current dir up to project root
100
+ discover_project_protocol_dirs.each do |dir|
101
+ load_directory_protocols(dir).each do |protocol_data|
102
+ key = protocol_data["protocol"]
103
+ @protocols[key] = protocol_data if key
104
+ end
105
+ end
106
+
107
+ @protocols
108
+ end
109
+
110
+ # Get list of valid protocol names
111
+ def valid_protocols
112
+ load_protocols.keys
113
+ end
114
+
115
+ # Check if a protocol is valid
116
+ def valid_protocol?(protocol)
117
+ valid_protocols.include?(protocol)
118
+ end
119
+
120
+ # Get all available configuration files
121
+ def available_configs
122
+ return [] unless @config_dir && Dir.exist?(@config_dir)
123
+
124
+ Dir.glob(File.join(@config_dir, "*.yml")).map do |path|
125
+ File.basename(path, ".yml")
126
+ end
127
+ end
128
+
129
+ # Get all discovered protocols with their metadata
130
+ def discovered_protocols
131
+ load_protocols
132
+ end
133
+
134
+ # Get sources for a specific protocol
135
+ def sources_for_protocol(protocol)
136
+ @source_registry.sources_for_protocol(protocol)
137
+ end
138
+
139
+ # Get the type of a protocol (cmd or file)
140
+ # @param protocol_name [String] The protocol name (e.g., "task", "wfi")
141
+ # @return [String] Protocol type: "cmd" for command delegation, "file" for file-based (default)
142
+ def protocol_type(protocol_name)
143
+ protocol_config = load_protocol_config(protocol_name)
144
+ protocol_config["type"] || "file"
145
+ end
146
+
147
+ private
148
+
149
+ # Load user config from explicit config directory
150
+ # @param config_dir [String] Path to .ace/nav directory
151
+ # @return [Hash] User configuration
152
+ def load_user_config_from_dir(config_dir)
153
+ config_path = File.join(config_dir, "config.yml")
154
+
155
+ if File.exist?(config_path)
156
+ load_yaml_file(config_path)
157
+ else
158
+ {}
159
+ end
160
+ end
161
+
162
+ def discover_project_protocol_dirs
163
+ dirs = []
164
+
165
+ # Use directory traverser to find all .ace directories up to project root
166
+ traverser = Ace::Support::Fs::Molecules::DirectoryTraverser.new(start_path: Dir.pwd)
167
+ config_dirs = traverser.find_config_directories
168
+
169
+ # Check each .ace directory for a protocols subdirectory
170
+ config_dirs.each do |config_dir|
171
+ protocol_dir = File.join(config_dir, "nav/protocols")
172
+ dirs << protocol_dir if Dir.exist?(protocol_dir)
173
+ end
174
+
175
+ dirs
176
+ end
177
+
178
+ def load_directory_protocols(dir_path)
179
+ protocols = []
180
+ return protocols unless Dir.exist?(dir_path)
181
+
182
+ Dir.glob(File.join(dir_path, "*.yml")).each do |file|
183
+ protocol_data = load_yaml_file(file)
184
+ protocols << protocol_data if protocol_data.is_a?(Hash)
185
+ end
186
+
187
+ protocols
188
+ end
189
+
190
+ def find_config_dir(config_dir)
191
+ return config_dir if config_dir
192
+
193
+ # Search for .ace/nav directory in cascade
194
+ search_paths = [
195
+ File.expand_path("./.ace/nav"), # Project level
196
+ File.expand_path("~/.ace/nav") # User level
197
+ ]
198
+
199
+ search_paths.find { |path| Dir.exist?(path) }
200
+ end
201
+
202
+ def load_yaml_file(path)
203
+ content = File.read(path)
204
+ YAML.safe_load(content, permitted_classes: [Symbol]) || {}
205
+ rescue => e
206
+ warn "Warning: Failed to load config from #{path}: #{e.message}"
207
+ {}
208
+ end
209
+
210
+ # Load only gem defaults (for fallback on config errors)
211
+ # Delegates to module-level method to avoid duplication
212
+ # @return [Hash] Gem defaults or empty hash
213
+ def load_gem_defaults_only
214
+ Ace::Support::Nav.load_gem_defaults_fallback
215
+ end
216
+
217
+ # Check if debug mode is enabled
218
+ # @return [Boolean] True if debug mode is enabled
219
+ def debug?
220
+ ENV["ACE_DEBUG"] == "1" || ENV["DEBUG"] == "1"
221
+ end
222
+
223
+ def default_protocol_config(protocol)
224
+ case protocol
225
+ when "wfi"
226
+ {
227
+ "workflows" => {
228
+ "extensions" => [".wfi.md", ".workflow.md"],
229
+ "default_dir" => "workflow-instructions"
230
+ }
231
+ }
232
+ when "tmpl"
233
+ {
234
+ "templates" => {
235
+ "extensions" => [".tmpl.md", ".template.md"],
236
+ "default_dir" => "templates"
237
+ }
238
+ }
239
+ when "guide"
240
+ {
241
+ "guides" => {
242
+ "extensions" => [".guide.md", ".md"],
243
+ "default_dir" => "guides"
244
+ }
245
+ }
246
+ when "sample"
247
+ {
248
+ "samples" => {
249
+ "extensions" => [],
250
+ "default_dir" => "samples"
251
+ }
252
+ }
253
+ when "task"
254
+ {
255
+ "tasks" => {
256
+ "search_paths" => [
257
+ "dev-taskflow/current/*/tasks",
258
+ "dev-taskflow/backlog"
259
+ ],
260
+ "extensions" => [".md"],
261
+ "autocorrect" => {
262
+ "enabled" => true,
263
+ "pad_zeros" => true
264
+ }
265
+ }
266
+ }
267
+ else
268
+ {}
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/gem_resolver"
4
+ require_relative "../atoms/path_normalizer"
5
+ require_relative "../models/handbook_source"
6
+ require_relative "config_loader"
7
+
8
+ module Ace
9
+ module Support
10
+ module Nav
11
+ module Molecules
12
+ # Scans and indexes available handbooks
13
+ class HandbookScanner
14
+ def initialize(gem_resolver: nil, path_normalizer: nil, config_loader: nil)
15
+ @gem_resolver = gem_resolver || Atoms::GemResolver.new
16
+ @path_normalizer = path_normalizer || Atoms::PathNormalizer.new
17
+ @config_loader = config_loader || ConfigLoader.new
18
+ @settings = @config_loader.load_settings
19
+ end
20
+
21
+ def scan_all_sources
22
+ sources = []
23
+
24
+ # Priority 1: Project overrides
25
+ sources << scan_project_source
26
+
27
+ # Priority 2: User overrides
28
+ sources << scan_user_source
29
+
30
+ # Priority 3: Gem handbooks
31
+ sources.concat(scan_gem_sources)
32
+
33
+ # Priority 4: Custom configured paths
34
+ sources.concat(scan_custom_sources)
35
+
36
+ # Filter out non-existent sources unless specifically configured
37
+ sources.compact.select { |s| s.exists? || s.custom? }
38
+ end
39
+
40
+ def scan_source_by_alias(alias_name)
41
+ return scan_project_source if alias_name == "@project"
42
+ return scan_user_source if alias_name == "@user"
43
+
44
+ # Check if it's a gem source
45
+ if alias_name.start_with?("@ace-")
46
+ gem_name = alias_name[1..] # Remove @
47
+ return scan_gem_source(gem_name)
48
+ end
49
+
50
+ # Check custom sources
51
+ scan_custom_source(alias_name)
52
+ end
53
+
54
+ def find_resources_in_source(source, protocol, pattern = "*")
55
+ return [] unless source&.exists?
56
+
57
+ handbook_path = source.handbook_path
58
+ protocol_dir = protocol_to_directory(protocol)
59
+ search_path = File.join(handbook_path, protocol_dir)
60
+
61
+ return [] unless Dir.exist?(search_path)
62
+
63
+ # Find matching files
64
+ extension = protocol_to_extension(protocol)
65
+ glob_pattern = File.join(search_path, "**", "#{pattern}#{extension}")
66
+
67
+ Dir.glob(glob_pattern).map do |file_path|
68
+ relative_path = file_path.sub("#{search_path}/", "").sub(extension, "")
69
+ {
70
+ path: file_path,
71
+ relative_path: relative_path,
72
+ source: source,
73
+ protocol: protocol
74
+ }
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def scan_project_source
81
+ project_path = File.expand_path("./.ace-handbook")
82
+ return nil unless Dir.exist?(project_path)
83
+
84
+ Models::HandbookSource.new(
85
+ name: "project",
86
+ path: project_path,
87
+ alias_name: "@project",
88
+ type: :project,
89
+ priority: 10
90
+ )
91
+ end
92
+
93
+ def scan_user_source
94
+ user_path = File.expand_path("~/.ace-handbook")
95
+ return nil unless Dir.exist?(user_path)
96
+
97
+ Models::HandbookSource.new(
98
+ name: "user",
99
+ path: user_path,
100
+ alias_name: "@user",
101
+ type: :user,
102
+ priority: 20
103
+ )
104
+ end
105
+
106
+ def scan_gem_sources
107
+ @gem_resolver.find_ace_gems.map.with_index do |gem_info, index|
108
+ next unless gem_info[:has_handbook]
109
+
110
+ Models::HandbookSource.new(
111
+ name: gem_info[:name],
112
+ path: gem_info[:path],
113
+ alias_name: "@#{gem_info[:name]}",
114
+ type: :gem,
115
+ priority: 100 + index
116
+ )
117
+ end.compact
118
+ end
119
+
120
+ def scan_gem_source(gem_name)
121
+ gem_info = @gem_resolver.find_gem_by_name(gem_name)
122
+ return nil unless gem_info
123
+ return nil unless gem_info[:has_handbook]
124
+
125
+ Models::HandbookSource.new(
126
+ name: gem_info[:name],
127
+ path: gem_info[:path],
128
+ alias_name: "@#{gem_info[:name]}",
129
+ type: :gem,
130
+ priority: 100
131
+ )
132
+ end
133
+
134
+ def scan_custom_sources
135
+ sources = []
136
+ config_sources = @settings.dig("handbooks", "sources") || []
137
+
138
+ config_sources.each do |source_config|
139
+ next if source_config["gem"] # Skip gem sources (handled elsewhere)
140
+
141
+ if source_config["path"]
142
+ path = File.expand_path(source_config["path"])
143
+ next unless Dir.exist?(path)
144
+
145
+ sources << Models::HandbookSource.new(
146
+ name: source_config["alias"] || File.basename(path),
147
+ path: path,
148
+ alias_name: "@#{source_config["alias"] || File.basename(path)}",
149
+ type: :custom,
150
+ priority: 200
151
+ )
152
+ end
153
+ end
154
+
155
+ sources
156
+ end
157
+
158
+ def scan_custom_source(alias_name)
159
+ config_sources = @settings.dig("handbooks", "sources") || []
160
+
161
+ # Remove @ prefix if present
162
+ search_alias = alias_name.start_with?("@") ? alias_name[1..] : alias_name
163
+
164
+ config_sources.each do |source_config|
165
+ next unless source_config["alias"] == search_alias
166
+
167
+ path = File.expand_path(source_config["path"])
168
+ return nil unless Dir.exist?(path)
169
+
170
+ return Models::HandbookSource.new(
171
+ name: source_config["alias"],
172
+ path: path,
173
+ alias_name: "@#{source_config["alias"]}",
174
+ type: :custom,
175
+ priority: 200
176
+ )
177
+ end
178
+
179
+ nil
180
+ end
181
+
182
+ def protocol_to_directory(protocol)
183
+ config = @config_loader.load_protocol_config(protocol)
184
+ return protocol unless config
185
+
186
+ # Use the directory specified in the protocol config
187
+ config["directory"] || protocol
188
+ end
189
+
190
+ def protocol_to_extension(protocol)
191
+ config = @config_loader.load_protocol_config(protocol)
192
+ return ".md" unless config
193
+
194
+ # Use the extensions specified in the protocol config
195
+ extensions = config["extensions"]
196
+ return "" if extensions.nil? || extensions.empty?
197
+
198
+ extensions.first
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end