ace-retro 0.16.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/wfi-sources/ace-retro.yml +19 -0
  3. data/.ace-defaults/retro/config.yml +16 -0
  4. data/CHANGELOG.md +252 -0
  5. data/LICENSE +21 -0
  6. data/README.md +40 -0
  7. data/Rakefile +13 -0
  8. data/docs/demo/ace-retro-getting-started.gif +0 -0
  9. data/docs/demo/ace-retro-getting-started.tape.yml +33 -0
  10. data/docs/demo/fixtures/README.md +3 -0
  11. data/docs/demo/fixtures/sample.txt +1 -0
  12. data/docs/getting-started.md +77 -0
  13. data/docs/handbook.md +60 -0
  14. data/docs/usage.md +141 -0
  15. data/exe/ace-retro +22 -0
  16. data/handbook/skills/as-handbook-selfimprove/SKILL.md +31 -0
  17. data/handbook/skills/as-retro-create/SKILL.md +26 -0
  18. data/handbook/skills/as-retro-synthesize/SKILL.md +26 -0
  19. data/handbook/templates/retro/retro.template.md +194 -0
  20. data/handbook/workflow-instructions/retro/create.wf.md +141 -0
  21. data/handbook/workflow-instructions/retro/selfimprove.wf.md +197 -0
  22. data/handbook/workflow-instructions/retro/synthesize.wf.md +94 -0
  23. data/lib/ace/retro/atoms/retro_file_pattern.rb +40 -0
  24. data/lib/ace/retro/atoms/retro_frontmatter_defaults.rb +42 -0
  25. data/lib/ace/retro/atoms/retro_id_formatter.rb +37 -0
  26. data/lib/ace/retro/atoms/retro_validation_rules.rb +82 -0
  27. data/lib/ace/retro/cli/commands/create.rb +87 -0
  28. data/lib/ace/retro/cli/commands/doctor.rb +204 -0
  29. data/lib/ace/retro/cli/commands/list.rb +63 -0
  30. data/lib/ace/retro/cli/commands/show.rb +55 -0
  31. data/lib/ace/retro/cli/commands/update.rb +117 -0
  32. data/lib/ace/retro/cli.rb +70 -0
  33. data/lib/ace/retro/models/retro.rb +40 -0
  34. data/lib/ace/retro/molecules/retro_config_loader.rb +93 -0
  35. data/lib/ace/retro/molecules/retro_creator.rb +165 -0
  36. data/lib/ace/retro/molecules/retro_display_formatter.rb +95 -0
  37. data/lib/ace/retro/molecules/retro_doctor_fixer.rb +404 -0
  38. data/lib/ace/retro/molecules/retro_doctor_reporter.rb +257 -0
  39. data/lib/ace/retro/molecules/retro_frontmatter_validator.rb +120 -0
  40. data/lib/ace/retro/molecules/retro_loader.rb +119 -0
  41. data/lib/ace/retro/molecules/retro_mover.rb +80 -0
  42. data/lib/ace/retro/molecules/retro_resolver.rb +57 -0
  43. data/lib/ace/retro/molecules/retro_scanner.rb +56 -0
  44. data/lib/ace/retro/molecules/retro_structure_validator.rb +193 -0
  45. data/lib/ace/retro/organisms/retro_doctor.rb +199 -0
  46. data/lib/ace/retro/organisms/retro_manager.rb +210 -0
  47. data/lib/ace/retro/version.rb +7 -0
  48. data/lib/ace/retro.rb +41 -0
  49. metadata +165 -0
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require "ace/llm"
6
+ require_relative "../../organisms/retro_doctor"
7
+ require_relative "../../molecules/retro_doctor_fixer"
8
+ require_relative "../../molecules/retro_doctor_reporter"
9
+ require_relative "../../molecules/retro_config_loader"
10
+
11
+ module Ace
12
+ module Retro
13
+ module CLI
14
+ module Commands
15
+ # ace-support-cli Command class for ace-retro doctor
16
+ #
17
+ # Runs health checks on retros and optionally auto-fixes issues.
18
+ class Doctor < Ace::Support::Cli::Command
19
+ include Ace::Support::Cli::Base
20
+
21
+ desc <<~DESC.strip
22
+ Run health checks on retros
23
+
24
+ Validates frontmatter, file structure, and scope/status consistency
25
+ across all retros in the repository. Supports auto-fixing safe issues.
26
+
27
+ DESC
28
+
29
+ example [
30
+ " # Run all health checks",
31
+ "--auto-fix # Auto-fix safe issues",
32
+ "--auto-fix --dry-run # Preview fixes without applying",
33
+ "--auto-fix-with-agent # Auto-fix then launch agent for remaining",
34
+ "--check frontmatter # Run specific check (frontmatter|structure|scope)",
35
+ "--json # Output as JSON",
36
+ "--verbose # Show all warnings"
37
+ ]
38
+
39
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
40
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
41
+ option :auto_fix, type: :boolean, aliases: %w[-f], desc: "Auto-fix safe issues"
42
+ option :auto_fix_with_agent, type: :boolean, desc: "Auto-fix then launch agent for remaining"
43
+ option :model, type: :string, desc: "Provider:model for agent session"
44
+ option :errors_only, type: :boolean, desc: "Show only errors, not warnings"
45
+ option :no_color, type: :boolean, desc: "Disable colored output"
46
+ option :json, type: :boolean, desc: "Output in JSON format"
47
+ option :dry_run, type: :boolean, aliases: %w[-n], desc: "Preview fixes without applying"
48
+ option :check, type: :string, desc: "Run specific check (frontmatter, structure, scope)"
49
+
50
+ def call(**options)
51
+ execute_doctor(options)
52
+ end
53
+
54
+ private
55
+
56
+ def execute_doctor(options)
57
+ config = Molecules::RetroConfigLoader.load
58
+ root_dir = Molecules::RetroConfigLoader.root_dir(config)
59
+
60
+ unless Dir.exist?(root_dir)
61
+ puts "Error: Retros directory not found: #{root_dir}"
62
+ raise Ace::Support::Cli::Error.new("Retros directory not found")
63
+ end
64
+
65
+ format = options[:json] ? :json : :terminal
66
+ fix = options[:auto_fix] || options[:auto_fix_with_agent]
67
+ colors = !options[:no_color]
68
+ colors = false if format == :json
69
+
70
+ doctor_opts = {}
71
+ doctor_opts[:check] = options[:check] if options[:check]
72
+
73
+ if options[:quiet]
74
+ results = run_diagnosis(root_dir, doctor_opts)
75
+ raise Ace::Support::Cli::Error.new("Health check failed") unless results[:valid]
76
+ return
77
+ end
78
+
79
+ results = run_diagnosis(root_dir, doctor_opts)
80
+
81
+ if options[:errors_only] && results[:issues]
82
+ results[:issues] = results[:issues].select { |i| i[:type] == :error }
83
+ end
84
+
85
+ output = Molecules::RetroDoctorReporter.format_results(
86
+ results,
87
+ format: format,
88
+ verbose: options[:verbose],
89
+ colors: colors
90
+ )
91
+ puts output
92
+
93
+ if fix && results[:issues]&.any?
94
+ handle_auto_fix(results, root_dir, doctor_opts, options, colors)
95
+ end
96
+
97
+ if options[:auto_fix_with_agent]
98
+ handle_agent_fix(root_dir, doctor_opts, options, config)
99
+ end
100
+
101
+ raise Ace::Support::Cli::Error.new("Health check failed") unless results[:valid]
102
+ rescue Ace::Support::Cli::Error
103
+ raise
104
+ rescue => e
105
+ raise Ace::Support::Cli::Error.new(e.message)
106
+ end
107
+
108
+ def run_diagnosis(root_dir, doctor_opts)
109
+ doctor = Organisms::RetroDoctor.new(root_dir, doctor_opts)
110
+ doctor.run_diagnosis
111
+ end
112
+
113
+ def handle_auto_fix(results, root_dir, doctor_opts, options, colors)
114
+ doctor = Organisms::RetroDoctor.new(root_dir, doctor_opts)
115
+ fixable_issues = results[:issues].select { |issue| doctor.auto_fixable?(issue) }
116
+
117
+ if fixable_issues.empty?
118
+ puts "\nNo auto-fixable issues found"
119
+ return
120
+ end
121
+
122
+ unless options[:quiet] || options[:dry_run]
123
+ puts "\nFound #{fixable_issues.size} auto-fixable issues"
124
+ print "Apply fixes? (y/N): "
125
+ response = $stdin.gets.chomp.downcase
126
+ return unless response == "y" || response == "yes"
127
+ end
128
+
129
+ fixer = Molecules::RetroDoctorFixer.new(dry_run: options[:dry_run], root_dir: root_dir)
130
+ fix_results = fixer.fix_issues(fixable_issues)
131
+
132
+ output = Molecules::RetroDoctorReporter.format_fix_results(
133
+ fix_results,
134
+ colors: colors
135
+ )
136
+ puts output
137
+
138
+ unless options[:dry_run]
139
+ puts "\nRe-running health check after fixes..."
140
+ new_results = run_diagnosis(root_dir, doctor_opts)
141
+
142
+ output = Molecules::RetroDoctorReporter.format_results(
143
+ new_results,
144
+ format: :summary,
145
+ verbose: false,
146
+ colors: colors
147
+ )
148
+ puts output
149
+ end
150
+ end
151
+
152
+ def handle_agent_fix(root_dir, doctor_opts, options, config)
153
+ results = run_diagnosis(root_dir, doctor_opts)
154
+ remaining = results[:issues]&.reject { |i| i[:type] == :info }
155
+
156
+ if remaining.nil? || remaining.empty?
157
+ puts "\nNo remaining issues for agent to fix."
158
+ return
159
+ end
160
+
161
+ issue_list = remaining.map { |i|
162
+ prefix = (i[:type] == :error) ? "ERROR" : "WARNING"
163
+ "- [#{prefix}] #{i[:message]}#{" (#{i[:location]})" if i[:location]}"
164
+ }.join("\n")
165
+
166
+ provider_model = options[:model] || config.dig("retro", "doctor_agent_model") || "gemini:flash-latest@yolo"
167
+
168
+ prompt = <<~PROMPT
169
+ The following #{remaining.size} retro issues could NOT be auto-fixed and need manual intervention:
170
+
171
+ #{issue_list}
172
+
173
+ ---
174
+
175
+ Fix each issue listed above in the .ace-retros/ directory.
176
+
177
+ IMPORTANT RULES:
178
+ - For invalid ID format issues, inspect the folder name and fix the frontmatter ID to match
179
+ - For YAML syntax errors, read the file and fix the YAML
180
+ - For missing opening delimiter, add '---' at the start of the file
181
+ - Do NOT delete content files — prefer fixing in place
182
+ - For folder naming issues, rename the folder to match {id}-{slug} convention
183
+
184
+ ---
185
+
186
+ Run `ace-retro doctor --verbose` to verify all issues are fixed.
187
+ PROMPT
188
+
189
+ puts "\nLaunching agent to fix #{remaining.size} remaining issues..."
190
+ query_options = {
191
+ system: nil,
192
+ timeout: 600,
193
+ fallback: false
194
+ }
195
+
196
+ response = Ace::LLM::QueryInterface.query(provider_model, prompt, **query_options)
197
+
198
+ puts response[:text]
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+
5
+ module Ace
6
+ module Retro
7
+ module CLI
8
+ module Commands
9
+ # ace-support-cli Command class for ace-retro list
10
+ class List < Ace::Support::Cli::Command
11
+ include Ace::Support::Cli::Base
12
+
13
+ C = Ace::Support::Items::Atoms::AnsiColors
14
+ desc "List retros\n\n" \
15
+ "Lists all retros with optional filtering by status, type, tags, or folder.\n\n" \
16
+ "Status legend:\n" \
17
+ " #{C::YELLOW}○ active#{C::RESET} #{C::GREEN}✓ done#{C::RESET}"
18
+ remove_const(:C)
19
+
20
+ example [
21
+ " # Active retros (root only, default)",
22
+ "--in all # All retros including archived",
23
+ "--in archive # Retros in _archive/",
24
+ "--status active # Filter by status",
25
+ "--type standard # Filter by type",
26
+ "--tags sprint,team # Retros matching any tag",
27
+ "--in archive --type standard # Combined filters"
28
+ ]
29
+
30
+ option :status, type: :string, aliases: %w[-s], desc: "Filter by status (active, done)"
31
+ option :type, type: :string, aliases: %w[-t], desc: "Filter by type (standard, conversation-analysis, self-review)"
32
+ option :tags, type: :string, aliases: %w[-T], desc: "Filter by tags (comma-separated, any match)"
33
+ option :in, type: :string, aliases: %w[-i], desc: "Filter by folder (next=root only [default], all=everything, archive)"
34
+ option :root, type: :string, aliases: %w[-r], desc: "Override root path"
35
+
36
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
37
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
38
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
39
+
40
+ def call(**options)
41
+ status = options[:status]
42
+ type = options[:type]
43
+ in_folder = options[:in]
44
+ tags_str = options[:tags]
45
+ tags = tags_str ? tags_str.split(",").map(&:strip).reject(&:empty?) : []
46
+
47
+ manager_opts = {}
48
+ manager_opts[:root_dir] = options[:root] if options[:root]
49
+ manager = Ace::Retro::Organisms::RetroManager.new(**manager_opts)
50
+ list_opts = {status: status, type: type, tags: tags}
51
+ list_opts[:in_folder] = in_folder if in_folder
52
+ retros = manager.list(**list_opts)
53
+
54
+ puts Ace::Retro::Molecules::RetroDisplayFormatter.format_list(
55
+ retros, total_count: manager.last_list_total,
56
+ global_folder_stats: manager.last_folder_counts
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+
5
+ module Ace
6
+ module Retro
7
+ module CLI
8
+ module Commands
9
+ # ace-support-cli Command class for ace-retro show
10
+ class Show < Ace::Support::Cli::Command
11
+ include Ace::Support::Cli::Base
12
+
13
+ desc <<~DESC.strip
14
+ Show retro details
15
+
16
+ Displays a retro by reference (full 6-char ID or last 3-char shortcut).
17
+
18
+ DESC
19
+
20
+ example [
21
+ "q7w # Formatted display (default)",
22
+ "8ppq7w --path # Print file path only",
23
+ "q7w --content # Print raw markdown content"
24
+ ]
25
+
26
+ argument :ref, required: true, desc: "Retro reference (6-char ID or 3-char shortcut)"
27
+
28
+ option :path, type: :boolean, desc: "Print file path only"
29
+ option :content, type: :boolean, desc: "Print raw markdown content"
30
+
31
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
32
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
33
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
34
+
35
+ def call(ref:, **options)
36
+ manager = Ace::Retro::Organisms::RetroManager.new
37
+ retro = manager.show(ref)
38
+
39
+ unless retro
40
+ raise Ace::Support::Cli::Error.new("Retro '#{ref}' not found")
41
+ end
42
+
43
+ if options[:path]
44
+ puts retro.file_path
45
+ elsif options[:content]
46
+ puts File.read(retro.file_path)
47
+ else
48
+ puts Ace::Retro::Molecules::RetroDisplayFormatter.format(retro, show_content: true)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/support/items"
5
+
6
+ module Ace
7
+ module Retro
8
+ module CLI
9
+ module Commands
10
+ # ace-support-cli Command class for ace-retro update
11
+ class Update < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+
14
+ desc <<~DESC.strip
15
+ Update retro metadata and/or move to a folder
16
+
17
+ Updates frontmatter fields using set, add, or remove operations.
18
+ Use --set for scalar fields, --add/--remove for array fields like tags.
19
+ Use --move-to to relocate to a special folder or back to root.
20
+ DESC
21
+
22
+ example [
23
+ "q7w --set status=done",
24
+ 'q7w --set status=done --set title="Refined title"',
25
+ "q7w --add tags=reviewed --remove tags=in-progress",
26
+ "q7w --set status=done --add tags=shipped",
27
+ "q7w --set status=done --move-to archive",
28
+ "q7w --move-to next"
29
+ ]
30
+
31
+ argument :ref, required: true, desc: "Retro reference (6-char ID or 3-char shortcut)"
32
+
33
+ option :set, type: :string, repeat: true, desc: "Set field: key=value (can repeat)"
34
+ option :add, type: :string, repeat: true, desc: "Add to array field: key=value (can repeat)"
35
+ option :remove, type: :string, repeat: true, desc: "Remove from array field: key=value (can repeat)"
36
+ option :move_to, type: :string, aliases: %w[-m], desc: "Move to folder (archive, next)"
37
+
38
+ option :git_commit, type: :boolean, aliases: %w[--gc], desc: "Auto-commit changes"
39
+
40
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
41
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
42
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
43
+
44
+ def call(ref:, **options)
45
+ set_args = Array(options[:set])
46
+ add_args = Array(options[:add])
47
+ remove_args = Array(options[:remove])
48
+ move_to = options[:move_to]
49
+
50
+ if set_args.empty? && add_args.empty? && remove_args.empty? && move_to.nil?
51
+ warn "Error: at least one of --set, --add, --remove, or --move-to is required"
52
+ warn ""
53
+ warn "Usage: ace-retro update REF [--set K=V]... [--add K=V]... [--remove K=V]... [--move-to FOLDER]"
54
+ raise Ace::Support::Cli::Error.new("No update operations specified")
55
+ end
56
+
57
+ set_hash = parse_kv_pairs(set_args)
58
+ add_hash = parse_kv_pairs(add_args)
59
+ remove_hash = parse_kv_pairs(remove_args)
60
+
61
+ manager = Ace::Retro::Organisms::RetroManager.new
62
+ retro = manager.update(ref, set: set_hash, add: add_hash, remove: remove_hash, move_to: move_to)
63
+
64
+ unless retro
65
+ raise Ace::Support::Cli::Error.new("Retro '#{ref}' not found")
66
+ end
67
+
68
+ if move_to
69
+ folder_info = retro.special_folder || "root"
70
+ puts "Retro updated: #{retro.id} #{retro.title} → #{folder_info}"
71
+ else
72
+ puts "Retro updated: #{retro.id} #{retro.title}"
73
+ end
74
+
75
+ if options[:git_commit]
76
+ commit_paths = move_to ? [manager.root_dir] : [retro.path]
77
+ intention = if move_to
78
+ "update retro #{retro.id} and move to #{retro.special_folder || "root"}"
79
+ else
80
+ "update retro #{retro.id}"
81
+ end
82
+ Ace::Support::Items::Molecules::GitCommitter.commit(
83
+ paths: commit_paths,
84
+ intention: intention
85
+ )
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ # Parse ["key=value", "key=value2"] into {"key" => typed_value, ...}
92
+ # Delegates to FieldArgumentParser for type inference (arrays, booleans, numerics).
93
+ def parse_kv_pairs(args)
94
+ result = {}
95
+ args.each do |arg|
96
+ unless arg.include?("=")
97
+ raise Ace::Support::Cli::Error.new("Invalid format '#{arg}': expected key=value")
98
+ end
99
+
100
+ parsed = Ace::Support::Items::Atoms::FieldArgumentParser.parse([arg])
101
+ parsed.each do |key, value|
102
+ result[key] = if result.key?(key)
103
+ Array(result[key]) + Array(value)
104
+ else
105
+ value
106
+ end
107
+ end
108
+ rescue Ace::Support::Items::Atoms::FieldArgumentParser::ParseError => e
109
+ raise Ace::Support::Cli::Error.new("Invalid argument '#{arg}': #{e.message}")
110
+ end
111
+ result
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "../retro/version"
6
+ require_relative "cli/commands/create"
7
+ require_relative "cli/commands/show"
8
+ require_relative "cli/commands/list"
9
+ require_relative "cli/commands/update"
10
+ require_relative "cli/commands/doctor"
11
+
12
+ module Ace
13
+ module Retro
14
+ # Flat CLI registry for ace-retro (retrospective management).
15
+ #
16
+ # Provides the flat `ace-retro <command>` invocation pattern.
17
+ module RetroCLI
18
+ extend Ace::Support::Cli::RegistryDsl
19
+
20
+ PROGRAM_NAME = "ace-retro"
21
+
22
+ REGISTERED_COMMANDS = [
23
+ ["create", "Create a new retro"],
24
+ ["show", "Show retro details"],
25
+ ["list", "List retros"],
26
+ ["update", "Update retro metadata (fields and move)"],
27
+ ["doctor", "Run health checks on retros"]
28
+ ].freeze
29
+
30
+ HELP_EXAMPLES = [
31
+ 'ace-retro create "Sprint Review" --type standard --tags sprint,team',
32
+ "ace-retro show q7w",
33
+ "ace-retro list --in archive --status active",
34
+ "ace-retro update q7w --set status=done --move-to archive",
35
+ "ace-retro update q7w --set status=done --add tags=reviewed",
36
+ "ace-retro update q7w --move-to next",
37
+ "ace-retro doctor --verbose"
38
+ ].freeze
39
+
40
+ register "create", CLI::Commands::Create
41
+ register "show", CLI::Commands::Show
42
+ register "list", CLI::Commands::List
43
+ register "update", CLI::Commands::Update
44
+ register "doctor", CLI::Commands::Doctor
45
+
46
+ version_cmd = Ace::Support::Cli::VersionCommand.build(
47
+ gem_name: "ace-retro",
48
+ version: Ace::Retro::VERSION
49
+ )
50
+ register "version", version_cmd
51
+ register "--version", version_cmd
52
+
53
+ help_cmd = Ace::Support::Cli::HelpCommand.build(
54
+ program_name: PROGRAM_NAME,
55
+ version: Ace::Retro::VERSION,
56
+ commands: REGISTERED_COMMANDS,
57
+ examples: HELP_EXAMPLES
58
+ )
59
+ register "help", help_cmd
60
+ register "--help", help_cmd
61
+ register "-h", help_cmd
62
+
63
+ # Entry point for CLI invocation
64
+ # @param args [Array<String>] Command-line arguments
65
+ def self.start(args)
66
+ Ace::Support::Cli::Runner.new(self).call(args: args)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Retro
5
+ module Models
6
+ # Value object representing a retrospective
7
+ # Holds all metadata and content for a single retro
8
+ Retro = Struct.new(
9
+ :id, # Raw 6-char b36ts ID (e.g., "8ppq7w")
10
+ :status, # Status string: "active", "done"
11
+ :title, # Human-readable title
12
+ :type, # Retro type: "standard", "conversation-analysis", "self-review"
13
+ :tags, # Array of tag strings
14
+ :content, # Body content (markdown, excluding frontmatter)
15
+ :path, # Directory path for the retro folder
16
+ :file_path, # Full path to the .retro.md file
17
+ :special_folder, # Special folder if any (e.g., "_archive", nil)
18
+ :created_at, # Time object for creation time
19
+ :folder_contents, # Array of additional filenames in the retro folder
20
+ :metadata, # Additional frontmatter fields as Hash
21
+ keyword_init: true
22
+ ) do
23
+ # Display-friendly representation
24
+ def to_s
25
+ "Retro(#{id}: #{title})"
26
+ end
27
+
28
+ # Short reference (last 3 chars of ID)
29
+ def shortcut
30
+ id[-3..] if id
31
+ end
32
+
33
+ # Check if retro is in a special folder
34
+ def special?
35
+ !special_folder.nil?
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "ace/support/fs"
5
+
6
+ module Ace
7
+ module Retro
8
+ module Molecules
9
+ # Loads and merges configuration for ace-retro from the cascade:
10
+ # .ace-defaults/retro/config.yml (gem) -> ~/.ace/retro/config.yml (user) -> .ace/retro/config.yml (project)
11
+ class RetroConfigLoader
12
+ DEFAULT_ROOT_DIR = ".ace-retros"
13
+
14
+ # Load configuration with cascade merge
15
+ # @param gem_root [String] Path to the ace-retro gem root
16
+ # @return [Hash] Merged configuration
17
+ def self.load(gem_root: nil)
18
+ gem_root ||= File.expand_path("../../../..", __dir__)
19
+ # lib/ace/retro/molecules/ → 4 levels up to gem root
20
+ new(gem_root: gem_root).load
21
+ end
22
+
23
+ def initialize(gem_root:)
24
+ @gem_root = gem_root
25
+ end
26
+
27
+ # Load and merge configuration
28
+ # @return [Hash] Merged configuration
29
+ def load
30
+ config = load_defaults
31
+ config = deep_merge(config, load_user_config)
32
+ deep_merge(config, load_project_config)
33
+ end
34
+
35
+ # Get the root directory for retros
36
+ # @param config [Hash] Configuration hash
37
+ # @return [String] Absolute path to retros root directory
38
+ def self.root_dir(config = nil)
39
+ config ||= load
40
+ dir = config.dig("retro", "root_dir") || DEFAULT_ROOT_DIR
41
+
42
+ if dir.start_with?("/")
43
+ dir
44
+ else
45
+ File.join(Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current, dir)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def load_defaults
52
+ path = File.join(@gem_root, ".ace-defaults", "retro", "config.yml")
53
+ load_yaml(path) || {}
54
+ end
55
+
56
+ def load_user_config
57
+ path = File.join(Dir.home, ".ace", "retro", "config.yml")
58
+ load_yaml(path) || {}
59
+ end
60
+
61
+ def load_project_config
62
+ path = File.join(Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current, ".ace", "retro", "config.yml")
63
+ load_yaml(path) || {}
64
+ end
65
+
66
+ def load_yaml(path)
67
+ return nil unless File.exist?(path)
68
+
69
+ YAML.safe_load_file(path, permitted_classes: [Date, Time, Symbol])
70
+ rescue Errno::ENOENT
71
+ nil
72
+ rescue Psych::SyntaxError => e
73
+ warn "Warning: ace-retro config parse error in #{path}: #{e.message}"
74
+ nil
75
+ end
76
+
77
+ def deep_merge(base, override)
78
+ return base unless override.is_a?(Hash)
79
+
80
+ result = base.dup
81
+ override.each do |key, value|
82
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
83
+ deep_merge(result[key], value)
84
+ else
85
+ value
86
+ end
87
+ end
88
+ result
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end