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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +174 -0
- data/README.md +29 -0
- data/Rakefile +13 -0
- data/lib/ace/support/items/atoms/ansi_colors.rb +37 -0
- data/lib/ace/support/items/atoms/date_partition_path.rb +24 -0
- data/lib/ace/support/items/atoms/field_argument_parser.rb +107 -0
- data/lib/ace/support/items/atoms/filter_parser.rb +79 -0
- data/lib/ace/support/items/atoms/folder_completion_detector.rb +40 -0
- data/lib/ace/support/items/atoms/frontmatter_parser.rb +57 -0
- data/lib/ace/support/items/atoms/frontmatter_serializer.rb +81 -0
- data/lib/ace/support/items/atoms/item_id_formatter.rb +81 -0
- data/lib/ace/support/items/atoms/item_id_parser.rb +89 -0
- data/lib/ace/support/items/atoms/item_statistics.rb +26 -0
- data/lib/ace/support/items/atoms/position_generator.rb +64 -0
- data/lib/ace/support/items/atoms/relative_time_formatter.rb +48 -0
- data/lib/ace/support/items/atoms/slug_sanitizer.rb +67 -0
- data/lib/ace/support/items/atoms/sort_score_calculator.rb +54 -0
- data/lib/ace/support/items/atoms/special_folder_detector.rb +101 -0
- data/lib/ace/support/items/atoms/stats_line_formatter.rb +85 -0
- data/lib/ace/support/items/atoms/title_extractor.rb +22 -0
- data/lib/ace/support/items/models/item_id.rb +55 -0
- data/lib/ace/support/items/models/loaded_document.rb +28 -0
- data/lib/ace/support/items/models/scan_result.rb +32 -0
- data/lib/ace/support/items/molecules/base_formatter.rb +51 -0
- data/lib/ace/support/items/molecules/directory_scanner.rb +106 -0
- data/lib/ace/support/items/molecules/document_loader.rb +77 -0
- data/lib/ace/support/items/molecules/field_updater.rb +120 -0
- data/lib/ace/support/items/molecules/filter_applier.rb +117 -0
- data/lib/ace/support/items/molecules/folder_mover.rb +91 -0
- data/lib/ace/support/items/molecules/git_committer.rb +21 -0
- data/lib/ace/support/items/molecules/item_sorter.rb +73 -0
- data/lib/ace/support/items/molecules/llm_slug_generator.rb +264 -0
- data/lib/ace/support/items/molecules/shortcut_resolver.rb +87 -0
- data/lib/ace/support/items/molecules/smart_sorter.rb +39 -0
- data/lib/ace/support/items/molecules/status_categorizer.rb +60 -0
- data/lib/ace/support/items/version.rb +9 -0
- data/lib/ace/support/items.rb +50 -0
- 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
|