ace-support-items 0.15.3

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +29 -0
  4. data/Rakefile +13 -0
  5. data/lib/ace/support/items/atoms/ansi_colors.rb +37 -0
  6. data/lib/ace/support/items/atoms/date_partition_path.rb +24 -0
  7. data/lib/ace/support/items/atoms/field_argument_parser.rb +107 -0
  8. data/lib/ace/support/items/atoms/filter_parser.rb +79 -0
  9. data/lib/ace/support/items/atoms/folder_completion_detector.rb +40 -0
  10. data/lib/ace/support/items/atoms/frontmatter_parser.rb +57 -0
  11. data/lib/ace/support/items/atoms/frontmatter_serializer.rb +81 -0
  12. data/lib/ace/support/items/atoms/item_id_formatter.rb +81 -0
  13. data/lib/ace/support/items/atoms/item_id_parser.rb +89 -0
  14. data/lib/ace/support/items/atoms/item_statistics.rb +26 -0
  15. data/lib/ace/support/items/atoms/position_generator.rb +64 -0
  16. data/lib/ace/support/items/atoms/relative_time_formatter.rb +48 -0
  17. data/lib/ace/support/items/atoms/slug_sanitizer.rb +67 -0
  18. data/lib/ace/support/items/atoms/sort_score_calculator.rb +54 -0
  19. data/lib/ace/support/items/atoms/special_folder_detector.rb +101 -0
  20. data/lib/ace/support/items/atoms/stats_line_formatter.rb +85 -0
  21. data/lib/ace/support/items/atoms/title_extractor.rb +22 -0
  22. data/lib/ace/support/items/models/item_id.rb +55 -0
  23. data/lib/ace/support/items/models/loaded_document.rb +28 -0
  24. data/lib/ace/support/items/models/scan_result.rb +32 -0
  25. data/lib/ace/support/items/molecules/base_formatter.rb +51 -0
  26. data/lib/ace/support/items/molecules/directory_scanner.rb +106 -0
  27. data/lib/ace/support/items/molecules/document_loader.rb +77 -0
  28. data/lib/ace/support/items/molecules/field_updater.rb +120 -0
  29. data/lib/ace/support/items/molecules/filter_applier.rb +117 -0
  30. data/lib/ace/support/items/molecules/folder_mover.rb +91 -0
  31. data/lib/ace/support/items/molecules/git_committer.rb +21 -0
  32. data/lib/ace/support/items/molecules/item_sorter.rb +73 -0
  33. data/lib/ace/support/items/molecules/llm_slug_generator.rb +264 -0
  34. data/lib/ace/support/items/molecules/shortcut_resolver.rb +87 -0
  35. data/lib/ace/support/items/molecules/smart_sorter.rb +39 -0
  36. data/lib/ace/support/items/molecules/status_categorizer.rb +60 -0
  37. data/lib/ace/support/items/version.rb +9 -0
  38. data/lib/ace/support/items.rb +50 -0
  39. metadata +111 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b2aa17665c5e6748f75a2f65366029ebeb0079edb2f0cd16e14a98bba9f5966e
4
+ data.tar.gz: 6eac1db061422fb21330c7e7327e25cd6a98d06ad6617a8ee686857a27e6764a
5
+ SHA512:
6
+ metadata.gz: fb9243adbcf8dea7becdee9d395c9cbba517b53f2c157567b4acfde17b9b75c37bd62f02ae5267ed060c328927d348fd0231946de7f621d714bd0661564b40cf
7
+ data.tar.gz: 00b1a7f87bc1981a55d00e9b77def6550133b6252f4eab37f01b00b66ff18853aca0c0ba761746ec78feff2563c5a34bc7907fe121b018f1ddb04088901f7fe0
data/CHANGELOG.md ADDED
@@ -0,0 +1,174 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.15.3] - 2026-03-22
11
+
12
+ ### Technical
13
+ - Updated README dependency example to use the current `~> 0.15` version constraint.
14
+
15
+ ## [0.15.2] - 2026-03-22
16
+
17
+ ### Technical
18
+ - Refreshed README structure with consistent tagline, installation, basic usage, and ACE project footer
19
+
20
+ ## [0.15.1] - 2026-03-04
21
+
22
+ ### Fixed
23
+ - Removed `Ace::Support::Items::Atoms::TmpWorkspace` and reverted to standard system temporary artifact handling.
24
+
25
+ ## [0.15.0] - 2026-03-04
26
+
27
+ ### Added
28
+ - New `Ace::Support::Items::Atoms::TmpWorkspace` atom for creating project-local, B36TS time-partitioned workspace directories under `.ace-local/tmp/`
29
+
30
+ ## [0.14.1] - 2026-03-03
31
+
32
+ ### Fixed
33
+ - `SpecialFolderDetector.normalize`: prefix-based expansion so any folder name is auto-expanded (e.g., `backlog` → `_backlog`), fixing `--in backlog` returning no results
34
+ - `StatsLineFormatter.folder_label`: uses `SpecialFolderDetector.short_name` instead of hardcoded `delete_prefix("_")`
35
+
36
+ ### Changed
37
+ - `SpecialFolderDetector`: replaced hardcoded `SPECIAL_FOLDERS` and `SHORT_ALIASES` constants with `DEFAULT_PREFIX = "_"` for deterministic two-way conversion
38
+ - `SpecialFolderDetector.special?`: accepts `prefix:` keyword parameter
39
+ - `SpecialFolderDetector.detect_in_path`: accepts `prefix:` keyword parameter
40
+
41
+ ### Added
42
+ - `SpecialFolderDetector.short_name`: reverse of `normalize`, strips prefix from folder name (e.g., `_archive` → `archive`)
43
+
44
+ ## [0.14.0] - 2026-03-03
45
+
46
+ ### Added
47
+ - `SlugSanitizer`: optional `max_length:` parameter (default: 55) for controlling slug length
48
+ - `SlugSanitizer`: word-boundary truncation via `truncate_at_word_boundary` — cuts at last hyphen instead of mid-word
49
+
50
+ ## [0.13.1] - 2026-03-03
51
+
52
+ ### Changed
53
+ - `StatsLineFormatter`: folder breakdown section (after em dash) is now dimmed via `AnsiColors` for visual distinction between current-view stats and system-wide totals
54
+ - `StatsLineFormatter`: folder names strip `_` prefix for display (`_archive` → `archive`, `_maybe` → `maybe`)
55
+
56
+ ## [0.13.0] - 2026-03-03
57
+
58
+ ### Added
59
+ - `AnsiColors` atom: TTY-aware ANSI color helpers with `colorize(text, color_code)` class method and color constants (RED, GREEN, YELLOW, CYAN, DIM, BOLD, RESET)
60
+ - `StatsLineFormatter`: new `global_folder_stats:` parameter that always appends folder breakdown to the stats line regardless of filtered/unfiltered state
61
+
62
+ ## [0.12.0] - 2026-03-02
63
+
64
+ ### Added
65
+ - `SortScoreCalculator` atom: computes sort scores using `priority_weight × 100 + age_days` with in-progress boost (+1000) and blocked penalty (×0.1), configurable weights and caps
66
+ - `PositionGenerator` atom: generates B36TS position values for pinning items in sort order (`first`, `last`, `after`, `before`, `between`)
67
+ - `SmartSorter` molecule: sorts items with pinned-first (by position ascending) + unpinned (by score descending) logic
68
+ - `StatusCategorizer` accepts optional `up_next_sorter:` proc for custom sort order in up-next bucket
69
+
70
+ ## [0.11.0] - 2026-03-02
71
+
72
+ ### Added
73
+ - `FolderCompletionDetector` atom: checks if all spec files in a directory have terminal status (done/skipped/blocked), with `recursive:` option for subtask subdirectories
74
+ - `SpecialFolderDetector.move_to_root?` method: recognizes "next", "root", and "/" as move-to-root aliases (case-insensitive)
75
+
76
+ ## [0.10.0] - 2026-03-02
77
+
78
+ ### Added
79
+ - `GitCommitter` molecule: shells out to `ace-git-commit` for auto-committing after CLI mutations, used by ace-task, ace-idea, and ace-retro `--git-commit` / `--gc` flag
80
+
81
+ ## [0.9.0] - 2026-03-02
82
+
83
+ ### Added
84
+ - `RelativeTimeFormatter` atom: formats a Time into human-readable relative strings (just now, 5m ago, 2h ago, 3d ago, 2w ago, 1mo ago, 1y ago) with injectable reference time
85
+ - `StatusCategorizer` molecule: categorizes items into "up next" (pending, root-only, sorted by ID) and "recently done" (done from all folders, sorted by file mtime desc) buckets for status overview displays
86
+
87
+ ## [0.8.1] - 2026-03-02
88
+
89
+ ### Fixed
90
+ - `DirectoryScanner` now recurses into special folders that contain orphan spec files (e.g., `_maybe/` with stray `.idea.s.md` files no longer blocks discovery of item subfolders)
91
+
92
+ ## [0.8.0] - 2026-03-02
93
+
94
+ ### Added
95
+ - `StatsLineFormatter.format` accepts `total_count:` parameter for "X of Y" filtered view display
96
+ - Filtered view (shown < total): displays "3 of 660" instead of redundant "3 total"
97
+ - Full view (shown == total): displays "660 total" with folder breakdown only when multi-folder
98
+
99
+ ### Changed
100
+ - Single-folder stats no longer show redundant folder breakdown (e.g., "3 total" instead of "3 total — next 3")
101
+
102
+ ## [0.7.0] - 2026-03-02
103
+
104
+ ### Added
105
+ - `ItemStatistics` atom: pure counting logic for grouping items by any field (`count_by`) and computing completion rates (`completion_rate`)
106
+ - `StatsLineFormatter` atom: generic stats summary line builder with configurable label, status icons, ordering, and optional completion percentage
107
+
108
+ ## [0.6.0] - 2026-03-02
109
+
110
+ ### Added
111
+ - `SpecialFolderDetector.virtual_filter?` method for resolving virtual filter names ("next", "all") to symbols
112
+ - `VIRTUAL_FILTERS` constant mapping virtual filter names to symbols (`:next`, `:all`)
113
+
114
+ ### Changed
115
+ - Remove `_next` from `SPECIAL_FOLDERS` and `SHORT_ALIASES` — "next" is now a virtual filter, not a physical folder
116
+ - `FolderMover#move` raises `ArgumentError` when target is a virtual filter ("next", "all")
117
+
118
+ ## [0.5.0] - 2026-03-01
119
+
120
+ ### Added
121
+ - `FieldUpdater` molecule for orchestrating --set/--add/--remove frontmatter field updates with nested dot-key support
122
+ - `FolderMover` molecule for generic folder moves with special folder normalization, archive partitioning, and cross-fs atomic moves
123
+ - `LlmSlugGenerator` molecule for LLM-powered slug generation with graceful fallback (moved from ace-taskflow)
124
+
125
+ ### Fixed
126
+ - `FrontmatterSerializer` now correctly serializes nested Hash values with proper YAML indentation (previously produced Ruby Hash#to_s)
127
+
128
+ ## [0.4.0] - 2026-03-01
129
+
130
+ ### Added
131
+ - `ItemIdFormatter` atom: splits 6-char b36ts IDs into type-marked format (`prefix.marker.suffix`) and reconstructs
132
+ - `ItemIdParser` atom: parses all reference forms (full, short, suffix, subtask, raw) into `ItemId` model
133
+ - `ItemId` model: value object with `raw_b36ts`, `prefix`, `type_marker`, `suffix`, `subtask_char`
134
+
135
+ ### Changed
136
+ - `DirectoryScanner`: added configurable `id_extractor:` proc parameter (default preserves existing 6-char behavior)
137
+ - `ShortcutResolver`: added `full_id_length:` parameter (default 6, set to 9 for type-marked IDs)
138
+
139
+ ## [0.3.0] - 2026-02-28
140
+
141
+ ### Added
142
+ - `DatePartitionPath` atom: computes a B36TS month/week partition path (e.g. `"8p/4"`) from a `Time` object for use in archive directory structures
143
+ - Runtime dependency on `ace-b36ts ~> 0.7`
144
+
145
+ ## [0.2.0] - 2026-02-28
146
+
147
+ ### Added
148
+
149
+ - `FrontmatterParser` atom for parsing YAML frontmatter from markdown files (tuple return: `[Hash, String]`)
150
+ - `FrontmatterSerializer` atom for serializing frontmatter hashes to YAML with inline arrays and value quoting
151
+ - `FilterParser` atom for parsing `--filter key:value` syntax with OR (`|`) and negation (`!`) support
152
+ - `TitleExtractor` atom for extracting first H1 heading from markdown body content
153
+ - `LoadedDocument` model as value object for parsed document with frontmatter, body, title, and attachments
154
+ - `DocumentLoader` molecule for loading documents from item directories with configurable file patterns
155
+ - `FilterApplier` molecule for applying parsed filter specs with AND/OR logic, negation, and custom value accessors
156
+ - `ItemSorter` molecule for sorting item collections by field with nil-last semantics
157
+ - `BaseFormatter` molecule with minimal default item/list formatting (overridable by gems)
158
+
159
+ ## [0.1.1] - 2026-02-28
160
+
161
+ ### Technical
162
+ - Moved `require "pathname"` to top-level in `SpecialFolderDetector` (was inline inside method)
163
+
164
+ ## [0.1.0] - 2026-02-28
165
+
166
+ ### Added
167
+
168
+ - Initial release with shared item management infrastructure
169
+ - `SlugSanitizer` atom for strict kebab-case slug sanitization
170
+ - `FieldArgumentParser` atom for parsing `key=value` CLI arguments with type inference
171
+ - `SpecialFolderDetector` atom for recognizing `_archive`, `_maybe`, `_anytime`, `_next` folders
172
+ - `ScanResult` model as value object for directory scan results
173
+ - `DirectoryScanner` molecule for recursive item directory scanning with special folder awareness
174
+ - `ShortcutResolver` molecule for resolving 3-char suffix shortcuts to full item IDs with ambiguity detection
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ <div align="center">
2
+ <h1> ACE - Support Items </h1>
3
+
4
+ Shared primitives for scanning, resolving, and sanitizing ACE item stores.
5
+
6
+ <img src="https://raw.githubusercontent.com/cs3b/ace/main/docs/brand/AgenticCodingEnvironment.Logo.XS.jpg" alt="ACE Logo" width="480">
7
+ <br><br>
8
+
9
+ <a href="https://rubygems.org/gems/ace-support-items"><img alt="Gem Version" src="https://img.shields.io/gem/v/ace-support-items.svg" /></a>
10
+ <a href="https://www.ruby-lang.org"><img alt="Ruby" src="https://img.shields.io/badge/Ruby-3.2+-CC342D?logo=ruby" /></a>
11
+ <a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg" /></a>
12
+
13
+ </div>
14
+
15
+ > Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
16
+
17
+ `ace-support-items` standardizes directory scanning, shortcut resolution, and slug handling for ACE item workflows. It provides the low-level store operations that packages like [ace-task](../ace-task) and [ace-retro](../ace-retro) build their item management on.
18
+
19
+ ## Use Cases
20
+
21
+ **Parse and resolve ACE task/idea shortcuts** - map compact IDs to canonical b36ts (base-36 timestamp) item paths, powering the shorthand lookups in [ace-task](../ace-task).
22
+
23
+ **Handle special item directories (underscore-prefixed folders like _maybe, _archive, and _anytime) consistently** - support shared folder conventions across tools so scanners in [ace-retro](../ace-retro) and [ace-task](../ace-task) discover items the same way.
24
+
25
+ **Normalize metadata safely** - sanitize slugs and arguments before persistence, preventing malformed entries in item stores.
26
+
27
+ ---
28
+
29
+ Part of [ACE](https://github.com/cs3b/ace)
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task spec: :test
13
+ task default: :test
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Simple ANSI color helpers for terminal output.
8
+ # Automatically skips color codes when stdout is not a TTY.
9
+ module AnsiColors
10
+ RED = "\e[31m"
11
+ GREEN = "\e[32m"
12
+ YELLOW = "\e[33m"
13
+ CYAN = "\e[36m"
14
+ DIM = "\e[2m"
15
+ BOLD = "\e[1m"
16
+ RESET = "\e[0m"
17
+
18
+ # Wrap text in ANSI color codes.
19
+ # Returns plain text when stdout is not a TTY.
20
+ # @param text [String] Text to colorize
21
+ # @param color_code [String] ANSI escape code (e.g. AnsiColors::GREEN)
22
+ # @return [String]
23
+ def self.colorize(text, color_code)
24
+ return text unless tty?
25
+
26
+ "#{color_code}#{text}#{RESET}"
27
+ end
28
+
29
+ # @return [Boolean] Whether stdout is a TTY
30
+ def self.tty?
31
+ $stdout.tty?
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Computes a date-based partition path using B36TS month+week split.
8
+ # Result: "8p/4" (month / week components joined with "/")
9
+ class DatePartitionPath
10
+ DEFAULT_LEVELS = %i[month week].freeze
11
+
12
+ # @param time [Time] The time to partition
13
+ # @param levels [Array<Symbol>] B36TS split levels (default: [:month, :week])
14
+ # @return [String] Path string e.g. "8p/4"
15
+ def self.compute(time, levels: DEFAULT_LEVELS)
16
+ require "ace/b36ts"
17
+ result = Ace::B36ts.encode_split(time, levels: levels)
18
+ levels.map { |l| result[l].to_s }.join("/")
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Parses command-line field arguments into typed hash values
8
+ # Handles type inference and array parsing for CLI input
9
+ class FieldArgumentParser
10
+ class ParseError < StandardError; end
11
+
12
+ # Parse field update arguments into structured format
13
+ # @param field_args [Array<String>] Field arguments in "key=value" format
14
+ # @return [Hash] Parsed field updates with keys and inferred values
15
+ # @raise [ParseError] If field syntax is invalid
16
+ def self.parse(field_args)
17
+ updates = {}
18
+
19
+ field_args.each do |arg|
20
+ # Match key=value with optional quotes around value
21
+ match = arg.match(/^([^=]+)=(.*)$/)
22
+ raise ParseError, "Invalid field syntax. Use: --field key=value" unless match
23
+
24
+ key = match[1].strip
25
+ value_str = match[2].strip
26
+
27
+ # Infer type from value string
28
+ value = infer_type(value_str)
29
+
30
+ # Store with key path for nested support
31
+ updates[key] = value
32
+ end
33
+
34
+ updates
35
+ end
36
+
37
+ # Infer value type from string
38
+ # @param value_str [String] String value from command line
39
+ # @return [Object] Inferred value (Integer, Boolean, Array, or String)
40
+ def self.infer_type(value_str)
41
+ # Remove surrounding quotes if present (must match)
42
+ if value_str.match?(/^"(.*)"$/) || value_str.match?(/^'(.*)'$/)
43
+ # Quoted string - strip quotes and return as string
44
+ return value_str[1..-2]
45
+ end
46
+
47
+ case value_str
48
+ when "" then ""
49
+ when "true" then true
50
+ when "false" then false
51
+ when /^-?\d+$/ then value_str.to_i
52
+ when /^-?\d+\.\d+$/ then value_str.to_f
53
+ when /^\[.*\]$/
54
+ content = value_str[1..-2].strip
55
+ return [] if content.empty?
56
+
57
+ # Parse array items handling quoted strings
58
+ items = parse_array_items(content)
59
+
60
+ # Try to infer types for array items
61
+ items.map { |item| infer_type(item) }
62
+ else
63
+ value_str
64
+ end
65
+ end
66
+
67
+ # Parse array items handling quoted strings with commas
68
+ # @param content [String] Array content without brackets
69
+ # @return [Array<String>] Parsed items
70
+ def self.parse_array_items(content)
71
+ items = []
72
+ current_item = ""
73
+ in_quotes = false
74
+ quote_char = nil
75
+ i = 0
76
+
77
+ while i < content.length
78
+ char = content[i]
79
+
80
+ if !in_quotes && (char == '"' || char == "'")
81
+ in_quotes = true
82
+ quote_char = char
83
+ current_item += char
84
+ elsif in_quotes && char == quote_char
85
+ in_quotes = false
86
+ quote_char = nil
87
+ current_item += char
88
+ elsif !in_quotes && char == ","
89
+ items << current_item.strip
90
+ current_item = ""
91
+ else
92
+ current_item += char
93
+ end
94
+
95
+ i += 1
96
+ end
97
+
98
+ items << current_item.strip unless current_item.strip.empty?
99
+ items.map { |item| item.empty? ? "" : item }
100
+ end
101
+
102
+ private_class_method :infer_type, :parse_array_items
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Parse `--filter key:value` syntax from command-line arguments.
8
+ # Supports simple values, OR (`key:a|b`), and negation (`key:!value`).
9
+ class FilterParser
10
+ # Parse filter strings into filter specifications
11
+ # @param filter_strings [Array<String>] Array of "key:value" filter strings
12
+ # @return [Array<Hash>] Array of filter specifications
13
+ # @raise [ArgumentError] If filter syntax is invalid
14
+ #
15
+ # Examples:
16
+ # parse(["status:pending"])
17
+ # # => [{key: "status", values: ["pending"], negated: false, or_mode: false}]
18
+ #
19
+ # parse(["status:pending|in-progress"])
20
+ # # => [{key: "status", values: ["pending", "in-progress"], negated: false, or_mode: true}]
21
+ #
22
+ # parse(["status:!done"])
23
+ # # => [{key: "status", values: ["done"], negated: true, or_mode: false}]
24
+ def self.parse(filter_strings)
25
+ return [] if filter_strings.nil? || filter_strings.empty?
26
+
27
+ Array(filter_strings).map do |filter_string|
28
+ parse_single_filter(filter_string)
29
+ end
30
+ end
31
+
32
+ # Parse a single filter string
33
+ # @param filter_string [String] Single "key:value" string
34
+ # @return [Hash] Filter specification
35
+ # @raise [ArgumentError] If syntax is invalid
36
+ private_class_method def self.parse_single_filter(filter_string)
37
+ unless filter_string.is_a?(String) && filter_string.include?(":")
38
+ raise ArgumentError, "Invalid filter syntax: '#{filter_string}'. Use: --filter key:value"
39
+ end
40
+
41
+ parts = filter_string.split(":", 2)
42
+ key = parts[0]&.strip
43
+ value_part = parts[1]&.strip
44
+
45
+ if key.nil? || key.empty?
46
+ raise ArgumentError, "Invalid filter syntax: missing key in '#{filter_string}'. Use: --filter key:value"
47
+ end
48
+
49
+ if value_part.nil? || value_part.empty?
50
+ raise ArgumentError, "Invalid filter syntax: missing value in '#{filter_string}'. Use: --filter key:value"
51
+ end
52
+
53
+ negated = value_part.start_with?("!")
54
+ value_part = value_part[1..] if negated
55
+
56
+ if value_part.nil? || value_part.empty?
57
+ raise ArgumentError, "Invalid filter syntax: empty value after negation in '#{filter_string}'"
58
+ end
59
+
60
+ values = value_part.split("|").map(&:strip).reject(&:empty?)
61
+
62
+ if values.empty?
63
+ raise ArgumentError, "Invalid filter syntax: no valid values in '#{filter_string}'"
64
+ end
65
+
66
+ or_mode = values.length > 1
67
+
68
+ {
69
+ key: key,
70
+ values: values,
71
+ negated: negated,
72
+ or_mode: or_mode
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "frontmatter_parser"
4
+
5
+ module Ace
6
+ module Support
7
+ module Items
8
+ module Atoms
9
+ # Detects whether all spec files in a folder have terminal status.
10
+ # Used by orchestrator auto-archive: when all subtasks are done/skipped/blocked,
11
+ # the parent can be auto-archived.
12
+ class FolderCompletionDetector
13
+ TERMINAL_STATUSES = %w[done skipped blocked].freeze
14
+
15
+ # Check if all spec files in a directory have terminal status.
16
+ #
17
+ # @param dir_path [String] Directory to check
18
+ # @param spec_pattern [String] Glob pattern for spec files (default: "*.s.md")
19
+ # @param terminal_statuses [Array<String>] Statuses considered terminal
20
+ # @param recursive [Boolean] If true, also check one level of subdirectories
21
+ # @return [Boolean] True if all found specs have terminal status; false if none found
22
+ def self.all_terminal?(dir_path, spec_pattern: "*.s.md", terminal_statuses: TERMINAL_STATUSES, recursive: false)
23
+ patterns = [File.join(dir_path, spec_pattern)]
24
+ patterns << File.join(dir_path, "*", spec_pattern) if recursive
25
+
26
+ files = patterns.flat_map { |p| Dir.glob(p) }
27
+ return false if files.empty?
28
+
29
+ files.all? do |file|
30
+ content = File.read(file)
31
+ frontmatter, = FrontmatterParser.parse(content)
32
+ status = frontmatter["status"].to_s.downcase
33
+ terminal_statuses.include?(status)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Support
7
+ module Items
8
+ module Atoms
9
+ # Pure function to parse YAML frontmatter from markdown files.
10
+ # Extracts frontmatter hash and body content from `---` delimited blocks.
11
+ class FrontmatterParser
12
+ # Parse YAML frontmatter from markdown content
13
+ # @param content [String] The markdown content with optional frontmatter
14
+ # @return [Hash] Parsed frontmatter data (empty hash if no frontmatter)
15
+ def self.parse_frontmatter(content)
16
+ return {} if content.nil? || content.empty?
17
+ return {} unless content.start_with?("---\n")
18
+
19
+ end_index = content.index("\n---\n", 4)
20
+ return {} unless end_index
21
+
22
+ yaml_content = content[4...end_index]
23
+
24
+ begin
25
+ YAML.safe_load(yaml_content, permitted_classes: [Date, Time, Symbol]) || {}
26
+ rescue Psych::SyntaxError
27
+ {}
28
+ end
29
+ end
30
+
31
+ # Extract content after frontmatter
32
+ # @param content [String] The markdown content with optional frontmatter
33
+ # @return [String] The content without frontmatter
34
+ def self.extract_body(content)
35
+ return "" if content.nil? || content.empty?
36
+
37
+ if content.start_with?("---\n")
38
+ end_index = content.index("\n---\n", 4)
39
+ if end_index
40
+ return content[(end_index + 5)..] || ""
41
+ end
42
+ end
43
+
44
+ content
45
+ end
46
+
47
+ # Parse both frontmatter and body
48
+ # @param content [String] The markdown content
49
+ # @return [Array(Hash, String)] Tuple of [frontmatter, body]
50
+ def self.parse(content)
51
+ [parse_frontmatter(content), extract_body(content)]
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Serializes frontmatter hashes to YAML block strings with `---` delimiters.
8
+ # Preserves inline-array style (`tags: [ux, design]`) and quotes
9
+ # YAML-ambiguous values.
10
+ class FrontmatterSerializer
11
+ YAML_AMBIGUOUS = /\A(true|false|yes|no|on|off|null|~|-?\d+(\.\d+)?([eE][+-]?\d+)?)\z/i
12
+
13
+ # Serialize frontmatter hash to YAML block string
14
+ # @param frontmatter [Hash] Frontmatter data
15
+ # @return [String] YAML frontmatter block including `---` delimiters
16
+ def self.serialize(frontmatter)
17
+ lines = ["---"]
18
+ frontmatter.each do |key, value|
19
+ serialize_entry(lines, key, value, indent: 0)
20
+ end
21
+ lines << "---"
22
+ lines.join("\n")
23
+ end
24
+
25
+ # Rebuild a full document from frontmatter and body
26
+ # @param frontmatter [Hash] Frontmatter data
27
+ # @param body [String] Document body content
28
+ # @return [String] Full document with frontmatter block and body
29
+ def self.rebuild(frontmatter, body)
30
+ "#{serialize(frontmatter)}\n\n#{body}"
31
+ end
32
+
33
+ # Serialize a single key-value entry with indentation support.
34
+ def self.serialize_entry(lines, key, value, indent:)
35
+ prefix = " " * indent
36
+ case value
37
+ when Hash
38
+ lines << "#{prefix}#{key}:"
39
+ value.each do |k, v|
40
+ serialize_entry(lines, k, v, indent: indent + 1)
41
+ end
42
+ when Array
43
+ lines << if value.empty?
44
+ "#{prefix}#{key}: []"
45
+ else
46
+ "#{prefix}#{key}: [#{value.join(", ")}]"
47
+ end
48
+ when String
49
+ lines << if needs_quoting?(value)
50
+ "#{prefix}#{key}: \"#{escape_yaml_string(value)}\""
51
+ else
52
+ "#{prefix}#{key}: #{value}"
53
+ end
54
+ else
55
+ lines << "#{prefix}#{key}: #{value}"
56
+ end
57
+ end
58
+
59
+ private_class_method :serialize_entry
60
+
61
+ # Check if a string value needs YAML quoting
62
+ # @param value [String] Value to check
63
+ # @return [Boolean]
64
+ private_class_method def self.needs_quoting?(value)
65
+ value.match?(YAML_AMBIGUOUS) ||
66
+ value.match?(/[:#@*&!%{}|>'"`\\,?\[\]]/) ||
67
+ value.start_with?(" ", "\t") || value.end_with?(" ", "\t") ||
68
+ value.include?("\n") || value.empty?
69
+ end
70
+
71
+ # Escape a string for double-quoted YAML
72
+ # @param value [String] Value to escape
73
+ # @return [String] Escaped value
74
+ private_class_method def self.escape_yaml_string(value)
75
+ value.gsub("\\", "\\\\\\\\").gsub('"', '\\"')
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end