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.
- checksums.yaml +7 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-retro.yml +19 -0
- data/.ace-defaults/retro/config.yml +16 -0
- data/CHANGELOG.md +252 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +13 -0
- data/docs/demo/ace-retro-getting-started.gif +0 -0
- data/docs/demo/ace-retro-getting-started.tape.yml +33 -0
- data/docs/demo/fixtures/README.md +3 -0
- data/docs/demo/fixtures/sample.txt +1 -0
- data/docs/getting-started.md +77 -0
- data/docs/handbook.md +60 -0
- data/docs/usage.md +141 -0
- data/exe/ace-retro +22 -0
- data/handbook/skills/as-handbook-selfimprove/SKILL.md +31 -0
- data/handbook/skills/as-retro-create/SKILL.md +26 -0
- data/handbook/skills/as-retro-synthesize/SKILL.md +26 -0
- data/handbook/templates/retro/retro.template.md +194 -0
- data/handbook/workflow-instructions/retro/create.wf.md +141 -0
- data/handbook/workflow-instructions/retro/selfimprove.wf.md +197 -0
- data/handbook/workflow-instructions/retro/synthesize.wf.md +94 -0
- data/lib/ace/retro/atoms/retro_file_pattern.rb +40 -0
- data/lib/ace/retro/atoms/retro_frontmatter_defaults.rb +42 -0
- data/lib/ace/retro/atoms/retro_id_formatter.rb +37 -0
- data/lib/ace/retro/atoms/retro_validation_rules.rb +82 -0
- data/lib/ace/retro/cli/commands/create.rb +87 -0
- data/lib/ace/retro/cli/commands/doctor.rb +204 -0
- data/lib/ace/retro/cli/commands/list.rb +63 -0
- data/lib/ace/retro/cli/commands/show.rb +55 -0
- data/lib/ace/retro/cli/commands/update.rb +117 -0
- data/lib/ace/retro/cli.rb +70 -0
- data/lib/ace/retro/models/retro.rb +40 -0
- data/lib/ace/retro/molecules/retro_config_loader.rb +93 -0
- data/lib/ace/retro/molecules/retro_creator.rb +165 -0
- data/lib/ace/retro/molecules/retro_display_formatter.rb +95 -0
- data/lib/ace/retro/molecules/retro_doctor_fixer.rb +404 -0
- data/lib/ace/retro/molecules/retro_doctor_reporter.rb +257 -0
- data/lib/ace/retro/molecules/retro_frontmatter_validator.rb +120 -0
- data/lib/ace/retro/molecules/retro_loader.rb +119 -0
- data/lib/ace/retro/molecules/retro_mover.rb +80 -0
- data/lib/ace/retro/molecules/retro_resolver.rb +57 -0
- data/lib/ace/retro/molecules/retro_scanner.rb +56 -0
- data/lib/ace/retro/molecules/retro_structure_validator.rb +193 -0
- data/lib/ace/retro/organisms/retro_doctor.rb +199 -0
- data/lib/ace/retro/organisms/retro_manager.rb +210 -0
- data/lib/ace/retro/version.rb +7 -0
- data/lib/ace/retro.rb +41 -0
- metadata +165 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/retro_file_pattern"
|
|
4
|
+
require_relative "../atoms/retro_id_formatter"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Retro
|
|
8
|
+
module Molecules
|
|
9
|
+
# Validates the directory structure of a retros root directory.
|
|
10
|
+
# Checks folder naming, file naming, and structural conventions.
|
|
11
|
+
class RetroStructureValidator
|
|
12
|
+
# @param root_dir [String] Root directory for retros (e.g., ".ace-retros")
|
|
13
|
+
def initialize(root_dir)
|
|
14
|
+
@root_dir = root_dir
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Validate the entire retros directory structure
|
|
18
|
+
# @return [Array<Hash>] List of issues found
|
|
19
|
+
def validate(root_dir = @root_dir)
|
|
20
|
+
issues = []
|
|
21
|
+
|
|
22
|
+
unless Dir.exist?(root_dir)
|
|
23
|
+
issues << {type: :error, message: "Retros root directory does not exist", location: root_dir}
|
|
24
|
+
return issues
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
check_folder_naming(root_dir, issues)
|
|
28
|
+
check_retro_files(root_dir, issues)
|
|
29
|
+
check_archive_partitions(root_dir, issues)
|
|
30
|
+
check_stale_backups(root_dir, issues)
|
|
31
|
+
check_empty_directories(root_dir, issues)
|
|
32
|
+
|
|
33
|
+
issues
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Check that retro folders follow {6-char-id}-{slug} naming
|
|
39
|
+
def check_folder_naming(root_dir, issues)
|
|
40
|
+
retro_dirs(root_dir).each do |dir|
|
|
41
|
+
folder_name = File.basename(dir)
|
|
42
|
+
next if folder_name.start_with?("_")
|
|
43
|
+
|
|
44
|
+
unless folder_name.match?(/^[0-9a-z]{6}-.+$/)
|
|
45
|
+
issues << {
|
|
46
|
+
type: :error,
|
|
47
|
+
message: "Folder name does not match '{id}-{slug}' convention: '#{folder_name}'",
|
|
48
|
+
location: dir
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Check that each retro folder has exactly one .retro.md file
|
|
55
|
+
def check_retro_files(root_dir, issues)
|
|
56
|
+
retro_dirs(root_dir).each do |dir|
|
|
57
|
+
folder_name = File.basename(dir)
|
|
58
|
+
next if folder_name.start_with?("_")
|
|
59
|
+
|
|
60
|
+
retro_files = Dir.glob(File.join(dir, Atoms::RetroFilePattern::FILE_GLOB))
|
|
61
|
+
|
|
62
|
+
if retro_files.empty?
|
|
63
|
+
issues << {
|
|
64
|
+
type: :warning,
|
|
65
|
+
message: "No .retro.md file in retro folder",
|
|
66
|
+
location: dir
|
|
67
|
+
}
|
|
68
|
+
elsif retro_files.size > 1
|
|
69
|
+
issues << {
|
|
70
|
+
type: :warning,
|
|
71
|
+
message: "Multiple .retro.md files in folder (#{retro_files.size} found)",
|
|
72
|
+
location: dir
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check for stale backup/tmp files
|
|
79
|
+
def check_stale_backups(root_dir, issues)
|
|
80
|
+
backup_patterns = [
|
|
81
|
+
File.join(root_dir, "**", "*.backup.*"),
|
|
82
|
+
File.join(root_dir, "**", "*.tmp"),
|
|
83
|
+
File.join(root_dir, "**", "*~")
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
backup_patterns.each do |pattern|
|
|
87
|
+
Dir.glob(pattern).each do |file|
|
|
88
|
+
next if file.include?("/.git/")
|
|
89
|
+
|
|
90
|
+
issues << {
|
|
91
|
+
type: :warning,
|
|
92
|
+
message: "Stale backup file (safe to delete)",
|
|
93
|
+
location: file
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Check for empty directories
|
|
100
|
+
def check_empty_directories(root_dir, issues)
|
|
101
|
+
Dir.glob(File.join(root_dir, "**", "*")).each do |path|
|
|
102
|
+
next unless File.directory?(path)
|
|
103
|
+
next if path.include?("/.git/")
|
|
104
|
+
|
|
105
|
+
files = Dir.glob(File.join(path, "**", "*")).select { |f| File.file?(f) }
|
|
106
|
+
if files.empty?
|
|
107
|
+
issues << {
|
|
108
|
+
type: :warning,
|
|
109
|
+
message: "Empty directory (safe to delete)",
|
|
110
|
+
location: path
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check that _archive/ partition directories use valid b36ts names
|
|
117
|
+
def check_archive_partitions(root_dir, issues)
|
|
118
|
+
archive_dir = File.join(root_dir, "_archive")
|
|
119
|
+
return unless Dir.exist?(archive_dir)
|
|
120
|
+
|
|
121
|
+
Dir.glob(File.join(archive_dir, "*")).each do |path|
|
|
122
|
+
next unless File.directory?(path)
|
|
123
|
+
next unless category_folder?(path)
|
|
124
|
+
|
|
125
|
+
partition_name = File.basename(path)
|
|
126
|
+
unless valid_b36ts_partition?(partition_name)
|
|
127
|
+
issues << {
|
|
128
|
+
type: :error,
|
|
129
|
+
message: "Invalid archive partition '#{partition_name}' (expected b36ts like '8o')",
|
|
130
|
+
location: path
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Valid b36ts month partitions are 1-3 char lowercase base36 strings
|
|
137
|
+
def valid_b36ts_partition?(name)
|
|
138
|
+
name.match?(/\A[0-9a-z]{1,3}\z/)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Find all immediate subdirectories that look like retro folders
|
|
142
|
+
# (excludes special folders which are containers, includes their children)
|
|
143
|
+
# Also excludes category folders (folders containing only subdirectories)
|
|
144
|
+
# For _archive/, recurses into partition dirs (category folders) to find retros inside them
|
|
145
|
+
def retro_dirs(root_dir)
|
|
146
|
+
dirs = []
|
|
147
|
+
|
|
148
|
+
Dir.glob(File.join(root_dir, "*")).each do |path|
|
|
149
|
+
next unless File.directory?(path)
|
|
150
|
+
|
|
151
|
+
folder_name = File.basename(path)
|
|
152
|
+
if folder_name == "_archive"
|
|
153
|
+
Dir.glob(File.join(path, "*")).each do |subpath|
|
|
154
|
+
next unless File.directory?(subpath)
|
|
155
|
+
|
|
156
|
+
if category_folder?(subpath)
|
|
157
|
+
# Partition dir — recurse into it to find retro folders
|
|
158
|
+
Dir.glob(File.join(subpath, "*")).each do |retro_path|
|
|
159
|
+
dirs << retro_path if File.directory?(retro_path)
|
|
160
|
+
end
|
|
161
|
+
else
|
|
162
|
+
dirs << subpath
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
elsif folder_name.start_with?("_")
|
|
166
|
+
Dir.glob(File.join(path, "*")).each do |subpath|
|
|
167
|
+
dirs << subpath if File.directory?(subpath) && !category_folder?(subpath)
|
|
168
|
+
end
|
|
169
|
+
else
|
|
170
|
+
dirs << path unless category_folder?(path)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
dirs
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Check if a folder is a category folder (only contains subdirectories, no files)
|
|
178
|
+
# These are organizational folders and not individual retros
|
|
179
|
+
def category_folder?(dir_path)
|
|
180
|
+
return false unless Dir.exist?(dir_path)
|
|
181
|
+
|
|
182
|
+
# Check if folder has any files
|
|
183
|
+
files = Dir.glob(File.join(dir_path, "*")).select { |f| File.file?(f) }
|
|
184
|
+
return false if files.any?
|
|
185
|
+
|
|
186
|
+
# Check if folder has any subdirectories
|
|
187
|
+
subdirs = Dir.glob(File.join(dir_path, "*")).select { |f| File.directory?(f) }
|
|
188
|
+
subdirs.any?
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../molecules/retro_scanner"
|
|
4
|
+
require_relative "../molecules/retro_loader"
|
|
5
|
+
require_relative "../molecules/retro_frontmatter_validator"
|
|
6
|
+
require_relative "../molecules/retro_structure_validator"
|
|
7
|
+
require_relative "../atoms/retro_validation_rules"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Retro
|
|
11
|
+
module Organisms
|
|
12
|
+
# Orchestrates comprehensive health checks for the retros system.
|
|
13
|
+
# Runs structure validation, frontmatter validation, and scope/status
|
|
14
|
+
# consistency checks across all retros in a root directory.
|
|
15
|
+
class RetroDoctor
|
|
16
|
+
attr_reader :root_path, :options
|
|
17
|
+
|
|
18
|
+
# @param root_path [String] Path to retros root directory
|
|
19
|
+
# @param options [Hash] Diagnosis options (:check, :verbose, etc.)
|
|
20
|
+
def initialize(root_path, options = {})
|
|
21
|
+
@root_path = root_path
|
|
22
|
+
@options = options
|
|
23
|
+
@issues = []
|
|
24
|
+
@stats = {
|
|
25
|
+
retros_scanned: 0,
|
|
26
|
+
folders_checked: 0,
|
|
27
|
+
errors: 0,
|
|
28
|
+
warnings: 0,
|
|
29
|
+
info: 0
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Run comprehensive health check
|
|
34
|
+
# @return [Hash] Diagnosis results
|
|
35
|
+
def run_diagnosis
|
|
36
|
+
unless @root_path && Dir.exist?(@root_path)
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
health_score: 0,
|
|
40
|
+
issues: [{type: :error, message: "Retros root directory not found: #{@root_path}"}],
|
|
41
|
+
stats: @stats,
|
|
42
|
+
duration: 0,
|
|
43
|
+
root_path: @root_path
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@start_time = Time.now
|
|
48
|
+
|
|
49
|
+
if options[:check]
|
|
50
|
+
run_specific_check(options[:check])
|
|
51
|
+
else
|
|
52
|
+
run_full_check
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
health_score = calculate_health_score
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
valid: @stats[:errors] == 0,
|
|
59
|
+
health_score: health_score,
|
|
60
|
+
issues: @issues,
|
|
61
|
+
stats: @stats,
|
|
62
|
+
duration: Time.now - @start_time,
|
|
63
|
+
root_path: @root_path
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check if an issue can be auto-fixed
|
|
68
|
+
# @param issue [Hash] Issue to check
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def auto_fixable?(issue)
|
|
71
|
+
return false unless issue[:type] == :error || issue[:type] == :warning
|
|
72
|
+
|
|
73
|
+
Molecules::RetroDoctorFixer::FIXABLE_PATTERNS.any? { |pattern| issue[:message].match?(pattern) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def run_full_check
|
|
79
|
+
run_structure_check
|
|
80
|
+
run_frontmatter_check
|
|
81
|
+
run_scope_check
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run_specific_check(check_type)
|
|
85
|
+
case check_type.to_s
|
|
86
|
+
when "structure"
|
|
87
|
+
run_structure_check
|
|
88
|
+
when "frontmatter"
|
|
89
|
+
run_frontmatter_check
|
|
90
|
+
when "scope"
|
|
91
|
+
run_scope_check
|
|
92
|
+
else
|
|
93
|
+
add_issue(:error, "Unknown check type: #{check_type}")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def run_structure_check
|
|
98
|
+
validator = Molecules::RetroStructureValidator.new(@root_path)
|
|
99
|
+
issues = validator.validate
|
|
100
|
+
|
|
101
|
+
issues.each do |issue|
|
|
102
|
+
add_issue(issue[:type], issue[:message], issue[:location])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@stats[:folders_checked] = count_retro_folders
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def run_frontmatter_check
|
|
109
|
+
scanner = Molecules::RetroScanner.new(@root_path)
|
|
110
|
+
return unless scanner.root_exists?
|
|
111
|
+
|
|
112
|
+
scan_results = scanner.scan
|
|
113
|
+
@stats[:retros_scanned] = scan_results.size
|
|
114
|
+
|
|
115
|
+
scan_results.each do |scan_result|
|
|
116
|
+
spec_file = scan_result.file_path
|
|
117
|
+
next unless spec_file && File.exist?(spec_file)
|
|
118
|
+
|
|
119
|
+
issues = Molecules::RetroFrontmatterValidator.validate(
|
|
120
|
+
spec_file,
|
|
121
|
+
special_folder: scan_result.special_folder
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Filter out scope issues (handled separately in run_scope_check)
|
|
125
|
+
issues.reject! { |i| scope_issue?(i[:message]) }
|
|
126
|
+
|
|
127
|
+
issues.each do |issue|
|
|
128
|
+
add_issue(issue[:type], issue[:message], issue[:location])
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def run_scope_check
|
|
134
|
+
scanner = Molecules::RetroScanner.new(@root_path)
|
|
135
|
+
return unless scanner.root_exists?
|
|
136
|
+
|
|
137
|
+
scan_results = scanner.scan
|
|
138
|
+
|
|
139
|
+
scan_results.each do |scan_result|
|
|
140
|
+
spec_file = scan_result.file_path
|
|
141
|
+
next unless spec_file && File.exist?(spec_file)
|
|
142
|
+
|
|
143
|
+
content = File.read(spec_file)
|
|
144
|
+
frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
145
|
+
next unless frontmatter.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
status = frontmatter["status"]
|
|
148
|
+
special_folder = scan_result.special_folder
|
|
149
|
+
|
|
150
|
+
scope_issues = Atoms::RetroValidationRules.scope_consistent?(status, special_folder)
|
|
151
|
+
scope_issues.each do |issue|
|
|
152
|
+
add_issue(issue[:type], issue[:message], spec_file)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def scope_issue?(message)
|
|
158
|
+
message.include?("not in _archive") ||
|
|
159
|
+
message.include?("in _archive/ but status")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def count_retro_folders
|
|
163
|
+
return 0 unless Dir.exist?(@root_path)
|
|
164
|
+
|
|
165
|
+
count = 0
|
|
166
|
+
Dir.glob(File.join(@root_path, "*")).each do |path|
|
|
167
|
+
next unless File.directory?(path)
|
|
168
|
+
|
|
169
|
+
count += if File.basename(path).start_with?("_")
|
|
170
|
+
Dir.glob(File.join(path, "*")).count { |p| File.directory?(p) }
|
|
171
|
+
else
|
|
172
|
+
1
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
count
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def add_issue(type, message, location = nil)
|
|
179
|
+
issue = {type: type, message: message}
|
|
180
|
+
issue[:location] = location if location
|
|
181
|
+
@issues << issue
|
|
182
|
+
|
|
183
|
+
case type
|
|
184
|
+
when :error then @stats[:errors] += 1
|
|
185
|
+
when :warning then @stats[:warnings] += 1
|
|
186
|
+
when :info then @stats[:info] += 1
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def calculate_health_score
|
|
191
|
+
score = 100
|
|
192
|
+
score -= @stats[:errors] * 10
|
|
193
|
+
score -= @stats[:warnings] * 2
|
|
194
|
+
[[score, 0].max, 100].min
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "../atoms/retro_frontmatter_defaults"
|
|
5
|
+
require_relative "../molecules/retro_config_loader"
|
|
6
|
+
require_relative "../molecules/retro_scanner"
|
|
7
|
+
require_relative "../molecules/retro_resolver"
|
|
8
|
+
require_relative "../molecules/retro_loader"
|
|
9
|
+
require_relative "../molecules/retro_creator"
|
|
10
|
+
require_relative "../molecules/retro_mover"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Retro
|
|
14
|
+
module Organisms
|
|
15
|
+
# Orchestrates all retro CRUD operations.
|
|
16
|
+
# Entry point for retro management with config-driven root directory.
|
|
17
|
+
class RetroManager
|
|
18
|
+
attr_reader :last_list_total, :last_folder_counts
|
|
19
|
+
|
|
20
|
+
# @param root_dir [String, nil] Override root directory for retros
|
|
21
|
+
# @param config [Hash, nil] Override configuration
|
|
22
|
+
def initialize(root_dir: nil, config: nil)
|
|
23
|
+
@config = config || load_config
|
|
24
|
+
@root_dir = root_dir || resolve_root_dir
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Create a new retro
|
|
28
|
+
# @param title [String] Retro title
|
|
29
|
+
# @param type [String, nil] Retro type (standard, conversation-analysis, self-review)
|
|
30
|
+
# @param tags [Array<String>] Tags
|
|
31
|
+
# @param move_to [String, nil] Target folder
|
|
32
|
+
# @return [Retro] Created retro
|
|
33
|
+
def create(title, type: nil, tags: [], move_to: nil)
|
|
34
|
+
ensure_root_dir
|
|
35
|
+
creator = Molecules::RetroCreator.new(root_dir: @root_dir, config: @config)
|
|
36
|
+
creator.create(title, type: type, tags: tags, move_to: move_to)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Show (load) a single retro by reference
|
|
40
|
+
# @param ref [String] Full ID (6 chars) or suffix shortcut (3 chars)
|
|
41
|
+
# @return [Retro, nil] Loaded retro or nil if not found
|
|
42
|
+
def show(ref)
|
|
43
|
+
resolver = Molecules::RetroResolver.new(@root_dir)
|
|
44
|
+
scan_result = resolver.resolve(ref)
|
|
45
|
+
return nil unless scan_result
|
|
46
|
+
|
|
47
|
+
loader = Molecules::RetroLoader.new
|
|
48
|
+
loader.load(scan_result.dir_path,
|
|
49
|
+
id: scan_result.id,
|
|
50
|
+
special_folder: scan_result.special_folder)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# List retros with optional filtering
|
|
54
|
+
# @param status [String, nil] Filter by status
|
|
55
|
+
# @param type [String, nil] Filter by type
|
|
56
|
+
# @param in_folder [String, nil] Filter by special folder (default: "next" = root items only)
|
|
57
|
+
# @param tags [Array<String>] Filter by tags (any match)
|
|
58
|
+
# @return [Array<Retro>] List of retros
|
|
59
|
+
def list(status: nil, type: nil, in_folder: "next", tags: [])
|
|
60
|
+
scanner = Molecules::RetroScanner.new(@root_dir)
|
|
61
|
+
scan_results = scanner.scan_in_folder(in_folder)
|
|
62
|
+
@last_list_total = scanner.last_scan_total
|
|
63
|
+
@last_folder_counts = scanner.last_folder_counts
|
|
64
|
+
|
|
65
|
+
loader = Molecules::RetroLoader.new
|
|
66
|
+
retros = scan_results.filter_map do |sr|
|
|
67
|
+
loader.load(sr.dir_path, id: sr.id, special_folder: sr.special_folder)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
retros = retros.select { |r| r.status == status } if status
|
|
71
|
+
retros = retros.select { |r| r.type == type } if type
|
|
72
|
+
retros = filter_by_tags(retros, tags) if tags.any?
|
|
73
|
+
|
|
74
|
+
retros
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Update a retro's fields and optionally move to a folder.
|
|
78
|
+
# @param ref [String] Retro reference
|
|
79
|
+
# @param set [Hash] Fields to set (key => value)
|
|
80
|
+
# @param add [Hash] Fields to add to (for arrays like tags)
|
|
81
|
+
# @param remove [Hash] Fields to remove from (for arrays)
|
|
82
|
+
# @param move_to [String, nil] Target folder to move to (archive, maybe, next/root//)
|
|
83
|
+
# @return [Retro, nil] Updated retro or nil if not found
|
|
84
|
+
def update(ref, set: {}, add: {}, remove: {}, move_to: nil)
|
|
85
|
+
scan_result = resolve_scan_result(ref)
|
|
86
|
+
return nil unless scan_result
|
|
87
|
+
|
|
88
|
+
loader = Molecules::RetroLoader.new
|
|
89
|
+
retro = loader.load(scan_result.dir_path,
|
|
90
|
+
id: scan_result.id,
|
|
91
|
+
special_folder: scan_result.special_folder)
|
|
92
|
+
return nil unless retro
|
|
93
|
+
|
|
94
|
+
# Apply field updates if any
|
|
95
|
+
has_field_updates = [set, add, remove].any? { |h| h && !h.empty? }
|
|
96
|
+
update_retro_file(retro, set: set, add: add, remove: remove) if has_field_updates
|
|
97
|
+
|
|
98
|
+
# Apply move if requested
|
|
99
|
+
current_path = retro.path
|
|
100
|
+
current_special = retro.special_folder
|
|
101
|
+
if move_to
|
|
102
|
+
mover = Molecules::RetroMover.new(@root_dir)
|
|
103
|
+
new_path = if Ace::Support::Items::Atoms::SpecialFolderDetector.move_to_root?(move_to)
|
|
104
|
+
mover.move_to_root(retro)
|
|
105
|
+
else
|
|
106
|
+
archive_date = parse_archive_date(retro)
|
|
107
|
+
mover.move(retro, to: move_to, date: archive_date)
|
|
108
|
+
end
|
|
109
|
+
current_path = new_path
|
|
110
|
+
current_special = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
|
|
111
|
+
new_path, root: @root_dir
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Reload and return updated retro
|
|
116
|
+
loader.load(current_path, id: retro.id, special_folder: current_special)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Get the root directory
|
|
120
|
+
# @return [String] Absolute path to retros root
|
|
121
|
+
attr_reader :root_dir
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def load_config
|
|
126
|
+
gem_root = File.expand_path("../../../..", __dir__)
|
|
127
|
+
# lib/ace/retro/organisms/ → 4 levels up to gem root
|
|
128
|
+
Molecules::RetroConfigLoader.load(gem_root: gem_root)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def resolve_root_dir
|
|
132
|
+
Molecules::RetroConfigLoader.root_dir(@config)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ensure_root_dir
|
|
136
|
+
require "fileutils"
|
|
137
|
+
FileUtils.mkdir_p(@root_dir) unless Dir.exist?(@root_dir)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def resolve_scan_result(ref)
|
|
141
|
+
resolver = Molecules::RetroResolver.new(@root_dir)
|
|
142
|
+
resolver.resolve(ref)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def filter_by_tags(retros, tags)
|
|
146
|
+
return retros if tags.empty?
|
|
147
|
+
|
|
148
|
+
retros.select do |retro|
|
|
149
|
+
tags.any? { |tag| retro.tags.include?(tag) }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def update_retro_file(retro, set:, add:, remove:)
|
|
154
|
+
content = File.read(retro.file_path)
|
|
155
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
156
|
+
# Strip leading newline from body so rebuild doesn't double-space
|
|
157
|
+
body = body.sub(/\A\n/, "")
|
|
158
|
+
|
|
159
|
+
# Apply set operations
|
|
160
|
+
set.each { |k, v| frontmatter[k.to_s] = v }
|
|
161
|
+
|
|
162
|
+
# Apply add operations (for arrays)
|
|
163
|
+
add.each do |k, v|
|
|
164
|
+
key = k.to_s
|
|
165
|
+
current = Array(frontmatter[key])
|
|
166
|
+
values = Array(v)
|
|
167
|
+
frontmatter[key] = (current + values).uniq
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Apply remove operations (for arrays)
|
|
171
|
+
remove.each do |k, v|
|
|
172
|
+
key = k.to_s
|
|
173
|
+
next unless frontmatter[key].is_a?(Array)
|
|
174
|
+
|
|
175
|
+
values = Array(v)
|
|
176
|
+
frontmatter[key] = frontmatter[key] - values
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Write back atomically (temp + rename to avoid partial writes)
|
|
180
|
+
new_content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body)
|
|
181
|
+
tmp_path = "#{retro.file_path}.tmp.#{Process.pid}"
|
|
182
|
+
File.write(tmp_path, new_content)
|
|
183
|
+
File.rename(tmp_path, retro.file_path)
|
|
184
|
+
ensure
|
|
185
|
+
begin
|
|
186
|
+
File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
187
|
+
rescue
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Extract archive date from retro frontmatter, falling back to Time.now
|
|
193
|
+
def parse_archive_date(retro)
|
|
194
|
+
raw = retro.metadata["completed_at"] || retro.created_at
|
|
195
|
+
return nil unless raw
|
|
196
|
+
|
|
197
|
+
case raw
|
|
198
|
+
when Time then raw
|
|
199
|
+
when DateTime then raw.to_time
|
|
200
|
+
else begin
|
|
201
|
+
Time.parse(raw.to_s)
|
|
202
|
+
rescue
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/ace/retro.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "retro/version"
|
|
4
|
+
|
|
5
|
+
# External dependencies
|
|
6
|
+
require "ace/b36ts"
|
|
7
|
+
require "ace/support/items"
|
|
8
|
+
|
|
9
|
+
# Atoms
|
|
10
|
+
require_relative "retro/atoms/retro_id_formatter"
|
|
11
|
+
require_relative "retro/atoms/retro_file_pattern"
|
|
12
|
+
require_relative "retro/atoms/retro_frontmatter_defaults"
|
|
13
|
+
require_relative "retro/atoms/retro_validation_rules"
|
|
14
|
+
|
|
15
|
+
# Models
|
|
16
|
+
require_relative "retro/models/retro"
|
|
17
|
+
|
|
18
|
+
# Molecules
|
|
19
|
+
require_relative "retro/molecules/retro_config_loader"
|
|
20
|
+
require_relative "retro/molecules/retro_scanner"
|
|
21
|
+
require_relative "retro/molecules/retro_resolver"
|
|
22
|
+
require_relative "retro/molecules/retro_loader"
|
|
23
|
+
require_relative "retro/molecules/retro_creator"
|
|
24
|
+
require_relative "retro/molecules/retro_mover"
|
|
25
|
+
require_relative "retro/molecules/retro_display_formatter"
|
|
26
|
+
require_relative "retro/molecules/retro_frontmatter_validator"
|
|
27
|
+
require_relative "retro/molecules/retro_structure_validator"
|
|
28
|
+
require_relative "retro/molecules/retro_doctor_fixer"
|
|
29
|
+
require_relative "retro/molecules/retro_doctor_reporter"
|
|
30
|
+
|
|
31
|
+
# Organisms
|
|
32
|
+
require_relative "retro/organisms/retro_manager"
|
|
33
|
+
require_relative "retro/organisms/retro_doctor"
|
|
34
|
+
|
|
35
|
+
module Ace
|
|
36
|
+
# Retro management gem for ACE.
|
|
37
|
+
# Manages retrospectives in .ace-retros/ using raw 6-char b36ts IDs.
|
|
38
|
+
module Retro
|
|
39
|
+
class Error < StandardError; end
|
|
40
|
+
end
|
|
41
|
+
end
|