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,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "../atoms/retro_id_formatter"
|
|
6
|
+
require_relative "../atoms/retro_file_pattern"
|
|
7
|
+
require_relative "../atoms/retro_frontmatter_defaults"
|
|
8
|
+
require_relative "retro_loader"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Retro
|
|
12
|
+
module Molecules
|
|
13
|
+
# Creates new retros with b36ts IDs, folder+file creation.
|
|
14
|
+
# Supports --type and --move-to options.
|
|
15
|
+
class RetroCreator
|
|
16
|
+
# @param root_dir [String] Root directory for retros
|
|
17
|
+
# @param config [Hash] Configuration hash
|
|
18
|
+
def initialize(root_dir:, config: {})
|
|
19
|
+
@root_dir = root_dir
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Create a new retro
|
|
24
|
+
# @param title [String] Retro title
|
|
25
|
+
# @param type [String] Retro type (standard, conversation-analysis, self-review)
|
|
26
|
+
# @param tags [Array<String>] Tags for the retro
|
|
27
|
+
# @param move_to [String, nil] Target folder for the retro
|
|
28
|
+
# @param time [Time] Creation time (default: now)
|
|
29
|
+
# @return [Retro] Created retro object
|
|
30
|
+
def create(title, type: nil, tags: [], move_to: nil, time: Time.now.utc)
|
|
31
|
+
raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
|
|
32
|
+
|
|
33
|
+
effective_type = type || @config.dig("retro", "default_type") || "standard"
|
|
34
|
+
|
|
35
|
+
# Generate ID and slugs
|
|
36
|
+
id = Atoms::RetroIdFormatter.generate(time)
|
|
37
|
+
folder_slug = generate_folder_slug(title)
|
|
38
|
+
file_slug = generate_file_slug(title)
|
|
39
|
+
|
|
40
|
+
# Determine target directory
|
|
41
|
+
target_dir = determine_target_dir(move_to)
|
|
42
|
+
FileUtils.mkdir_p(target_dir)
|
|
43
|
+
|
|
44
|
+
# Create retro folder (ensure unique name if ID collision occurs)
|
|
45
|
+
folder_name, _ = unique_folder_name(id, folder_slug, target_dir)
|
|
46
|
+
retro_dir = File.join(target_dir, folder_name)
|
|
47
|
+
FileUtils.mkdir_p(retro_dir)
|
|
48
|
+
|
|
49
|
+
# Build frontmatter
|
|
50
|
+
frontmatter = Atoms::RetroFrontmatterDefaults.build(
|
|
51
|
+
id: id,
|
|
52
|
+
title: title,
|
|
53
|
+
type: effective_type,
|
|
54
|
+
tags: tags,
|
|
55
|
+
status: "active",
|
|
56
|
+
created_at: time
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Write retro file
|
|
60
|
+
file_content = build_file_content(frontmatter, title, effective_type)
|
|
61
|
+
retro_filename = Atoms::RetroFilePattern.retro_filename(id, file_slug)
|
|
62
|
+
retro_file = File.join(retro_dir, retro_filename)
|
|
63
|
+
File.write(retro_file, file_content)
|
|
64
|
+
|
|
65
|
+
# Load and return the created retro
|
|
66
|
+
loader = RetroLoader.new
|
|
67
|
+
special_folder = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
|
|
68
|
+
retro_dir, root: @root_dir
|
|
69
|
+
)
|
|
70
|
+
loader.load(retro_dir, id: id, special_folder: special_folder)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Ensure unique folder name when the same b36ts ID is generated within the
|
|
76
|
+
# same 2-second window. If the candidate folder already exists, appends a
|
|
77
|
+
# numeric counter to the slug: {id}-{slug}-2, {id}-{slug}-3, etc.
|
|
78
|
+
# @return [Array<String>] [folder_name, effective_slug]
|
|
79
|
+
def unique_folder_name(id, slug, target_dir)
|
|
80
|
+
folder_name = Atoms::RetroFilePattern.folder_name(id, slug)
|
|
81
|
+
candidate_dir = File.join(target_dir, folder_name)
|
|
82
|
+
|
|
83
|
+
return [folder_name, slug] unless Dir.exist?(candidate_dir)
|
|
84
|
+
|
|
85
|
+
counter = 2
|
|
86
|
+
loop do
|
|
87
|
+
unique_slug = "#{slug}-#{counter}"
|
|
88
|
+
folder_name = Atoms::RetroFilePattern.folder_name(id, unique_slug)
|
|
89
|
+
candidate_dir = File.join(target_dir, folder_name)
|
|
90
|
+
break [folder_name, unique_slug] unless Dir.exist?(candidate_dir)
|
|
91
|
+
|
|
92
|
+
counter += 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def generate_folder_slug(title)
|
|
97
|
+
sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
|
|
98
|
+
words = sanitized.split("-")
|
|
99
|
+
words.take(5).join("-").then { |s| s.empty? ? "retro" : s }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def generate_file_slug(title)
|
|
103
|
+
sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
|
|
104
|
+
words = sanitized.split("-")
|
|
105
|
+
words.take(7).join("-").then { |s| s.empty? ? "retro" : s }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def determine_target_dir(move_to)
|
|
109
|
+
if move_to
|
|
110
|
+
normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(move_to)
|
|
111
|
+
candidate = File.expand_path(File.join(@root_dir, normalized))
|
|
112
|
+
root_real = File.expand_path(@root_dir)
|
|
113
|
+
unless candidate.start_with?(root_real + File::SEPARATOR) || candidate == root_real
|
|
114
|
+
raise ArgumentError, "Path traversal detected in --move-to option"
|
|
115
|
+
end
|
|
116
|
+
candidate
|
|
117
|
+
else
|
|
118
|
+
@root_dir
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_file_content(frontmatter, title, type)
|
|
123
|
+
fm_str = Atoms::RetroFrontmatterDefaults.serialize(frontmatter)
|
|
124
|
+
|
|
125
|
+
body = retro_template(type)
|
|
126
|
+
|
|
127
|
+
"#{fm_str}\n\n# #{title}\n\n#{body}\n"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def retro_template(type)
|
|
131
|
+
case type
|
|
132
|
+
when "conversation-analysis"
|
|
133
|
+
<<~BODY
|
|
134
|
+
## Context
|
|
135
|
+
|
|
136
|
+
## Key Observations
|
|
137
|
+
|
|
138
|
+
## Patterns Identified
|
|
139
|
+
|
|
140
|
+
## Action Items
|
|
141
|
+
BODY
|
|
142
|
+
when "self-review"
|
|
143
|
+
<<~BODY
|
|
144
|
+
## What I Did Well
|
|
145
|
+
|
|
146
|
+
## What I Could Improve
|
|
147
|
+
|
|
148
|
+
## Key Learnings
|
|
149
|
+
|
|
150
|
+
## Action Items
|
|
151
|
+
BODY
|
|
152
|
+
else # standard
|
|
153
|
+
<<~BODY
|
|
154
|
+
## What Went Well
|
|
155
|
+
|
|
156
|
+
## What Could Be Improved
|
|
157
|
+
|
|
158
|
+
## Action Items
|
|
159
|
+
BODY
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Retro
|
|
5
|
+
module Molecules
|
|
6
|
+
# Formats retro objects for terminal display.
|
|
7
|
+
class RetroDisplayFormatter
|
|
8
|
+
STATUS_SYMBOLS = {
|
|
9
|
+
"active" => "○",
|
|
10
|
+
"done" => "✓"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
STATUS_COLORS = {
|
|
14
|
+
"active" => Ace::Support::Items::Atoms::AnsiColors::YELLOW,
|
|
15
|
+
"done" => Ace::Support::Items::Atoms::AnsiColors::GREEN
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
TYPE_LABELS = {
|
|
19
|
+
"standard" => "standard",
|
|
20
|
+
"conversation-analysis" => "conversation",
|
|
21
|
+
"self-review" => "self-review"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Return the status symbol with ANSI color applied.
|
|
25
|
+
def self.colored_status_sym(status)
|
|
26
|
+
sym = STATUS_SYMBOLS[status] || "○"
|
|
27
|
+
color = STATUS_COLORS[status]
|
|
28
|
+
color ? Ace::Support::Items::Atoms::AnsiColors.colorize(sym, color) : sym
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private_class_method :colored_status_sym
|
|
32
|
+
|
|
33
|
+
# Format a single retro for display
|
|
34
|
+
# @param retro [Retro] Retro to format
|
|
35
|
+
# @param show_content [Boolean] Whether to include full content
|
|
36
|
+
# @return [String] Formatted output
|
|
37
|
+
def self.format(retro, show_content: false)
|
|
38
|
+
c = Ace::Support::Items::Atoms::AnsiColors
|
|
39
|
+
status_sym = colored_status_sym(retro.status)
|
|
40
|
+
id_str = show_content ? retro.id : c.colorize(retro.id, c::DIM)
|
|
41
|
+
tags_str = retro.tags.any? ? c.colorize(" [#{retro.tags.join(", ")}]", c::DIM) : ""
|
|
42
|
+
folder_str = retro.special_folder ? c.colorize(" (#{retro.special_folder})", c::DIM) : ""
|
|
43
|
+
type_str = c.colorize(" <#{TYPE_LABELS[retro.type] || retro.type}>", c::DIM)
|
|
44
|
+
lines = []
|
|
45
|
+
lines << "#{status_sym} #{id_str} #{retro.title}#{type_str}#{tags_str}#{folder_str}"
|
|
46
|
+
|
|
47
|
+
if show_content && retro.content && !retro.content.strip.empty?
|
|
48
|
+
lines << ""
|
|
49
|
+
lines << retro.content
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if retro.folder_contents&.any?
|
|
53
|
+
lines << ""
|
|
54
|
+
lines << "Files: #{retro.folder_contents.join(", ")}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
lines.join("\n")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Format a list of retros for display
|
|
61
|
+
# @param retros [Array<Retro>] Retros to format
|
|
62
|
+
# @param total_count [Integer, nil] Total items before folder filtering
|
|
63
|
+
# @param global_folder_stats [Hash, nil] Folder name → count hash from full scan
|
|
64
|
+
# @return [String] Formatted list output
|
|
65
|
+
def self.format_list(retros, total_count: nil, global_folder_stats: nil)
|
|
66
|
+
return "No retros found." if retros.empty?
|
|
67
|
+
|
|
68
|
+
lines = retros.map { |retro| format(retro) }.join("\n")
|
|
69
|
+
"#{lines}\n\n#{format_stats_line(retros, total_count: total_count, global_folder_stats: global_folder_stats)}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
STATUS_ORDER = %w[active done].freeze
|
|
73
|
+
|
|
74
|
+
# Format a stats summary line for a list of retros.
|
|
75
|
+
# @param retros [Array<Retro>] Retros to summarize
|
|
76
|
+
# @param total_count [Integer, nil] Total items before folder filtering
|
|
77
|
+
# @param global_folder_stats [Hash, nil] Folder name → count hash from full scan
|
|
78
|
+
# @return [String] e.g. "Retros: ○ 2 | ✓ 5 • 2 of 7"
|
|
79
|
+
def self.format_stats_line(retros, total_count: nil, global_folder_stats: nil)
|
|
80
|
+
stats = Ace::Support::Items::Atoms::ItemStatistics.count_by(retros, :status)
|
|
81
|
+
folder_stats = Ace::Support::Items::Atoms::ItemStatistics.count_by(retros, :special_folder)
|
|
82
|
+
Ace::Support::Items::Atoms::StatsLineFormatter.format(
|
|
83
|
+
label: "Retros",
|
|
84
|
+
stats: stats,
|
|
85
|
+
status_order: STATUS_ORDER,
|
|
86
|
+
status_icons: STATUS_SYMBOLS,
|
|
87
|
+
folder_stats: folder_stats,
|
|
88
|
+
total_count: total_count,
|
|
89
|
+
global_folder_stats: global_folder_stats
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "ace/support/markdown"
|
|
5
|
+
require "ace/support/items"
|
|
6
|
+
require_relative "../atoms/retro_id_formatter"
|
|
7
|
+
require_relative "../atoms/retro_validation_rules"
|
|
8
|
+
require_relative "../atoms/retro_frontmatter_defaults"
|
|
9
|
+
require_relative "retro_loader"
|
|
10
|
+
require_relative "retro_mover"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Retro
|
|
14
|
+
module Molecules
|
|
15
|
+
# Handles auto-fixing of common retro issues detected by doctor.
|
|
16
|
+
# Supports dry_run mode to preview fixes without applying them.
|
|
17
|
+
class RetroDoctorFixer
|
|
18
|
+
attr_reader :dry_run, :fixed_count, :skipped_count
|
|
19
|
+
|
|
20
|
+
def initialize(dry_run: false, root_dir: nil)
|
|
21
|
+
@dry_run = dry_run
|
|
22
|
+
@root_dir = root_dir
|
|
23
|
+
@fixed_count = 0
|
|
24
|
+
@skipped_count = 0
|
|
25
|
+
@fixes_applied = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Fix a batch of issues
|
|
29
|
+
# @param issues [Array<Hash>] Issues to fix
|
|
30
|
+
# @return [Hash] Fix results summary
|
|
31
|
+
def fix_issues(issues)
|
|
32
|
+
fixable_issues = issues.select { |issue| can_fix?(issue) }
|
|
33
|
+
|
|
34
|
+
fixable_issues.each do |issue|
|
|
35
|
+
fix_issue(issue)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
fixed: @fixed_count,
|
|
40
|
+
skipped: @skipped_count,
|
|
41
|
+
fixes_applied: @fixes_applied,
|
|
42
|
+
dry_run: @dry_run
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Fix a single issue by pattern matching its message
|
|
47
|
+
# @param issue [Hash] Issue to fix
|
|
48
|
+
# @return [Boolean] Whether fix was successful
|
|
49
|
+
def fix_issue(issue)
|
|
50
|
+
case issue[:message]
|
|
51
|
+
when /Missing opening '---' delimiter/
|
|
52
|
+
fix_missing_opening_delimiter(issue[:location])
|
|
53
|
+
when /Missing closing '---' delimiter/
|
|
54
|
+
fix_missing_closing_delimiter(issue[:location])
|
|
55
|
+
when /Missing required field: id/
|
|
56
|
+
fix_missing_id(issue[:location])
|
|
57
|
+
when /Missing required field: status/,
|
|
58
|
+
/Missing recommended field: status/
|
|
59
|
+
fix_missing_status(issue[:location])
|
|
60
|
+
when /Missing required field: title/,
|
|
61
|
+
/Missing recommended field: title/
|
|
62
|
+
fix_missing_title(issue[:location])
|
|
63
|
+
when /Missing required field: type/
|
|
64
|
+
fix_missing_type(issue[:location])
|
|
65
|
+
when /Missing required field: created_at/
|
|
66
|
+
fix_missing_created_at(issue[:location])
|
|
67
|
+
when /Field 'tags' is not an array/
|
|
68
|
+
fix_tags_not_array(issue[:location])
|
|
69
|
+
when /Missing recommended field: tags/
|
|
70
|
+
fix_missing_tags(issue[:location])
|
|
71
|
+
when /terminal status.*not in _archive/
|
|
72
|
+
fix_move_to_archive(issue[:location])
|
|
73
|
+
when /in _archive\/ but status is/
|
|
74
|
+
fix_archive_status(issue[:location])
|
|
75
|
+
when /Invalid archive partition/
|
|
76
|
+
fix_invalid_archive_partition(issue[:location])
|
|
77
|
+
when /Stale backup file/
|
|
78
|
+
fix_stale_backup(issue[:location])
|
|
79
|
+
when /Empty directory/
|
|
80
|
+
fix_empty_directory(issue[:location])
|
|
81
|
+
else
|
|
82
|
+
@skipped_count += 1
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if an issue can be auto-fixed
|
|
88
|
+
# @param issue [Hash] Issue to check
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def can_fix?(issue)
|
|
91
|
+
return false unless issue[:location]
|
|
92
|
+
|
|
93
|
+
FIXABLE_PATTERNS.any? { |pattern| issue[:message].match?(pattern) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
FIXABLE_PATTERNS = [
|
|
97
|
+
/Missing opening '---' delimiter/,
|
|
98
|
+
/Missing closing '---' delimiter/,
|
|
99
|
+
/Missing required field: id/,
|
|
100
|
+
/Missing required field: status/,
|
|
101
|
+
/Missing required field: title/,
|
|
102
|
+
/Missing required field: type/,
|
|
103
|
+
/Missing required field: created_at/,
|
|
104
|
+
/Missing recommended field: status/,
|
|
105
|
+
/Missing recommended field: title/,
|
|
106
|
+
/Missing recommended field: tags/,
|
|
107
|
+
/Field 'tags' is not an array/,
|
|
108
|
+
/terminal status.*not in _archive/,
|
|
109
|
+
/in _archive\/ but status is/,
|
|
110
|
+
/Invalid archive partition/,
|
|
111
|
+
/Stale backup file/,
|
|
112
|
+
/Empty directory/
|
|
113
|
+
].freeze
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def fix_missing_closing_delimiter(file_path)
|
|
118
|
+
return false unless File.exist?(file_path)
|
|
119
|
+
|
|
120
|
+
content = File.read(file_path)
|
|
121
|
+
lines = content.lines
|
|
122
|
+
insert_idx = nil
|
|
123
|
+
lines[1..].each_with_index do |line, i|
|
|
124
|
+
if line.strip.empty? || line.start_with?("#")
|
|
125
|
+
insert_idx = i + 1
|
|
126
|
+
break
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
insert_idx ||= lines.size
|
|
130
|
+
|
|
131
|
+
fixed_lines = lines.dup
|
|
132
|
+
fixed_lines.insert(insert_idx, "---\n")
|
|
133
|
+
fixed_content = fixed_lines.join
|
|
134
|
+
|
|
135
|
+
apply_file_fix(file_path, fixed_content, "Added missing closing '---' delimiter")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fix_missing_opening_delimiter(file_path)
|
|
139
|
+
return false unless File.exist?(file_path)
|
|
140
|
+
|
|
141
|
+
content = File.read(file_path)
|
|
142
|
+
dir_name = File.basename(File.dirname(file_path))
|
|
143
|
+
id_match = dir_name.match(/^([0-9a-z]{6})/)
|
|
144
|
+
id = id_match ? id_match[1] : nil
|
|
145
|
+
|
|
146
|
+
title = extract_title_from_content(content) || extract_slug_title(dir_name)
|
|
147
|
+
|
|
148
|
+
frontmatter = Atoms::RetroFrontmatterDefaults.build(
|
|
149
|
+
id: id || Atoms::RetroIdFormatter.generate,
|
|
150
|
+
title: title,
|
|
151
|
+
status: "active",
|
|
152
|
+
created_at: Time.now.utc
|
|
153
|
+
)
|
|
154
|
+
frontmatter["id"] = id if id
|
|
155
|
+
|
|
156
|
+
yaml_block = Atoms::RetroFrontmatterDefaults.serialize(frontmatter)
|
|
157
|
+
new_content = "#{yaml_block}\n#{content}"
|
|
158
|
+
|
|
159
|
+
apply_file_fix(file_path, new_content, "Added opening '---' delimiter and frontmatter")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def fix_missing_id(file_path)
|
|
163
|
+
return false unless File.exist?(file_path)
|
|
164
|
+
|
|
165
|
+
dir_name = File.basename(File.dirname(file_path))
|
|
166
|
+
id_match = dir_name.match(/^([0-9a-z]{6})/)
|
|
167
|
+
unless id_match
|
|
168
|
+
return (@skipped_count += 1
|
|
169
|
+
false)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
id = id_match[1]
|
|
173
|
+
update_frontmatter_field(file_path, "id", id, "Added missing 'id' field from folder name")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def fix_missing_status(file_path)
|
|
177
|
+
update_frontmatter_field(file_path, "status", "active", "Added missing 'status' field with default 'active'")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def fix_missing_title(file_path)
|
|
181
|
+
return false unless File.exist?(file_path)
|
|
182
|
+
|
|
183
|
+
content = File.read(file_path)
|
|
184
|
+
title = nil
|
|
185
|
+
_fm, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
186
|
+
if body
|
|
187
|
+
h1_match = body.match(/^#\s+(.+)/)
|
|
188
|
+
title = h1_match[1].strip if h1_match
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
unless title
|
|
192
|
+
dir_name = File.basename(File.dirname(file_path))
|
|
193
|
+
slug_match = dir_name.match(/^[0-9a-z]{6}-(.+)$/)
|
|
194
|
+
title = slug_match ? slug_match[1].tr("-", " ").capitalize : "Untitled"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
update_frontmatter_field(file_path, "title", title, "Added missing 'title' field: '#{title}'")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def fix_missing_type(file_path)
|
|
201
|
+
update_frontmatter_field(file_path, "type", "standard", "Added missing 'type' field with default 'standard'")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def fix_missing_created_at(file_path)
|
|
205
|
+
return false unless File.exist?(file_path)
|
|
206
|
+
|
|
207
|
+
content = File.read(file_path)
|
|
208
|
+
frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
209
|
+
|
|
210
|
+
id = frontmatter&.dig("id")
|
|
211
|
+
unless id
|
|
212
|
+
match = File.basename(File.dirname(file_path)).match(/^([0-9a-z]{6})/)
|
|
213
|
+
id = match[1] if match
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
created_at = if id && Atoms::RetroIdFormatter.valid?(id)
|
|
217
|
+
Atoms::RetroIdFormatter.decode_time(id).strftime("%Y-%m-%d %H:%M:%S")
|
|
218
|
+
else
|
|
219
|
+
Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
update_frontmatter_field(file_path, "created_at", created_at, "Added missing 'created_at' field")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def fix_tags_not_array(file_path)
|
|
226
|
+
update_frontmatter_field(file_path, "tags", [], "Coerced 'tags' field to empty array")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def fix_missing_tags(file_path)
|
|
230
|
+
update_frontmatter_field(file_path, "tags", [], "Added missing 'tags' field with empty array")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def fix_move_to_archive(file_path)
|
|
234
|
+
return false unless file_path && @root_dir
|
|
235
|
+
|
|
236
|
+
retro_dir = File.directory?(file_path) ? file_path : File.dirname(file_path)
|
|
237
|
+
folder_name = File.basename(retro_dir)
|
|
238
|
+
partition = Ace::Support::Items::Atoms::DatePartitionPath.compute(Time.now, levels: [:month])
|
|
239
|
+
archive_dir = File.join(@root_dir, "_archive", partition)
|
|
240
|
+
target = File.join(archive_dir, folder_name)
|
|
241
|
+
|
|
242
|
+
if @dry_run
|
|
243
|
+
log_fix(retro_dir, "Would move to _archive/#{partition}/")
|
|
244
|
+
@fixed_count += 1
|
|
245
|
+
return true
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
FileUtils.mkdir_p(archive_dir)
|
|
249
|
+
if File.exist?(target)
|
|
250
|
+
return (@skipped_count += 1
|
|
251
|
+
false)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
FileUtils.mv(retro_dir, target)
|
|
255
|
+
log_fix(retro_dir, "Moved to _archive/#{partition}/")
|
|
256
|
+
@fixed_count += 1
|
|
257
|
+
true
|
|
258
|
+
rescue
|
|
259
|
+
@skipped_count += 1
|
|
260
|
+
false
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def fix_archive_status(file_path)
|
|
264
|
+
update_frontmatter_field(file_path, "status", "done", "Updated status to 'done' (in _archive/)")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def fix_invalid_archive_partition(partition_dir)
|
|
268
|
+
return false unless partition_dir && Dir.exist?(partition_dir) && @root_dir
|
|
269
|
+
|
|
270
|
+
loader = RetroLoader.new
|
|
271
|
+
mover = RetroMover.new(@root_dir)
|
|
272
|
+
moved = 0
|
|
273
|
+
|
|
274
|
+
Dir.glob(File.join(partition_dir, "*")).each do |retro_path|
|
|
275
|
+
next unless File.directory?(retro_path)
|
|
276
|
+
|
|
277
|
+
retro = loader.load(retro_path, special_folder: "_archive")
|
|
278
|
+
next unless retro
|
|
279
|
+
|
|
280
|
+
if @dry_run
|
|
281
|
+
partition = Ace::Support::Items::Atoms::DatePartitionPath.compute(retro.created_at || Time.now)
|
|
282
|
+
log_fix(retro_path, "Would move to _archive/#{partition}/")
|
|
283
|
+
else
|
|
284
|
+
mover.move(retro, to: "archive", date: retro.created_at)
|
|
285
|
+
end
|
|
286
|
+
moved += 1
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Remove empty partition dir
|
|
290
|
+
unless @dry_run
|
|
291
|
+
remaining = Dir.glob(File.join(partition_dir, "*"))
|
|
292
|
+
FileUtils.rmdir(partition_dir) if remaining.empty?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
if moved > 0
|
|
296
|
+
unless @dry_run
|
|
297
|
+
log_fix(partition_dir, "Relocated #{moved} retro(s) to b36ts partition(s)")
|
|
298
|
+
end
|
|
299
|
+
@fixed_count += 1
|
|
300
|
+
true
|
|
301
|
+
else
|
|
302
|
+
@skipped_count += 1
|
|
303
|
+
false
|
|
304
|
+
end
|
|
305
|
+
rescue
|
|
306
|
+
@skipped_count += 1
|
|
307
|
+
false
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def fix_stale_backup(file_path)
|
|
311
|
+
return false unless file_path && File.exist?(file_path)
|
|
312
|
+
|
|
313
|
+
if @dry_run
|
|
314
|
+
log_fix(file_path, "Would delete stale backup file")
|
|
315
|
+
else
|
|
316
|
+
File.delete(file_path)
|
|
317
|
+
log_fix(file_path, "Deleted stale backup file")
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
@fixed_count += 1
|
|
321
|
+
true
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def fix_empty_directory(dir_path)
|
|
325
|
+
return false unless dir_path && Dir.exist?(dir_path)
|
|
326
|
+
|
|
327
|
+
files = Dir.glob(File.join(dir_path, "**", "*")).select { |f| File.file?(f) }
|
|
328
|
+
unless files.empty?
|
|
329
|
+
@skipped_count += 1
|
|
330
|
+
return false
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
if @dry_run
|
|
334
|
+
log_fix(dir_path, "Would delete empty directory")
|
|
335
|
+
else
|
|
336
|
+
FileUtils.rm_rf(dir_path)
|
|
337
|
+
log_fix(dir_path, "Deleted empty directory")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
@fixed_count += 1
|
|
341
|
+
true
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def extract_title_from_content(content)
|
|
345
|
+
h1_match = content.match(/^#\s+(.+)$/)
|
|
346
|
+
h1_match ? h1_match[1].strip : nil
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def extract_slug_title(dir_name)
|
|
350
|
+
slug_match = dir_name.match(/^[0-9a-z]{6}-(.+)$/)
|
|
351
|
+
slug = slug_match ? slug_match[1] : dir_name
|
|
352
|
+
slug.tr("-", " ").capitalize
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def update_frontmatter_field(file_path, field, value, description)
|
|
356
|
+
return false unless file_path && File.exist?(file_path)
|
|
357
|
+
|
|
358
|
+
if @dry_run
|
|
359
|
+
log_fix(file_path, "Would: #{description}")
|
|
360
|
+
@fixed_count += 1
|
|
361
|
+
return true
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
editor = Ace::Support::Markdown::Organisms::DocumentEditor.new(file_path)
|
|
365
|
+
editor.update_frontmatter(field => value)
|
|
366
|
+
editor.save!(backup: true, validate_before: false)
|
|
367
|
+
log_fix(file_path, description)
|
|
368
|
+
@fixed_count += 1
|
|
369
|
+
true
|
|
370
|
+
rescue
|
|
371
|
+
@skipped_count += 1
|
|
372
|
+
false
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def apply_file_fix(file_path, new_content, description)
|
|
376
|
+
if @dry_run
|
|
377
|
+
log_fix(file_path, "Would: #{description}")
|
|
378
|
+
else
|
|
379
|
+
Ace::Support::Markdown::Organisms::SafeFileWriter.write(
|
|
380
|
+
file_path,
|
|
381
|
+
new_content,
|
|
382
|
+
backup: true
|
|
383
|
+
)
|
|
384
|
+
log_fix(file_path, description)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
@fixed_count += 1
|
|
388
|
+
true
|
|
389
|
+
rescue
|
|
390
|
+
@skipped_count += 1
|
|
391
|
+
false
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def log_fix(file_path, description)
|
|
395
|
+
@fixes_applied << {
|
|
396
|
+
file: file_path,
|
|
397
|
+
description: description,
|
|
398
|
+
timestamp: Time.now
|
|
399
|
+
}
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|