ace-assign 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +211 -0
  3. data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +44 -0
  4. data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +35 -0
  5. data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +32 -0
  6. data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +29 -0
  7. data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +48 -0
  8. data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +34 -0
  9. data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +22 -0
  10. data/.ace-defaults/assign/catalog/steps/commit.step.yml +22 -0
  11. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +28 -0
  12. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +22 -0
  13. data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +22 -0
  14. data/.ace-defaults/assign/catalog/steps/lint.step.yml +22 -0
  15. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +57 -0
  16. data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +19 -0
  17. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +19 -0
  18. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +17 -0
  19. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +34 -0
  20. data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +28 -0
  21. data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +28 -0
  22. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +57 -0
  23. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +23 -0
  24. data/.ace-defaults/assign/catalog/steps/release.step.yml +23 -0
  25. data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +28 -0
  26. data/.ace-defaults/assign/catalog/steps/research.step.yml +19 -0
  27. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +22 -0
  28. data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +22 -0
  29. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +25 -0
  30. data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +28 -0
  31. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +29 -0
  32. data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +38 -0
  33. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +28 -0
  34. data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +42 -0
  35. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +48 -0
  36. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +36 -0
  37. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +23 -0
  38. data/.ace-defaults/assign/config.yml +48 -0
  39. data/.ace-defaults/assign/presets/fix-bug.yml +65 -0
  40. data/.ace-defaults/assign/presets/quick-implement.yml +41 -0
  41. data/.ace-defaults/assign/presets/release-only.yml +35 -0
  42. data/.ace-defaults/assign/presets/work-on-docs.yml +41 -0
  43. data/.ace-defaults/assign/presets/work-on-task.yml +179 -0
  44. data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +19 -0
  45. data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +19 -0
  46. data/CHANGELOG.md +1415 -0
  47. data/README.md +87 -0
  48. data/Rakefile +16 -0
  49. data/docs/exit-codes.md +61 -0
  50. data/docs/getting-started.md +121 -0
  51. data/docs/handbook.md +40 -0
  52. data/docs/usage.md +224 -0
  53. data/exe/ace-assign +16 -0
  54. data/handbook/guides/fork-context.g.md +231 -0
  55. data/handbook/skills/as-assign-compose/SKILL.md +24 -0
  56. data/handbook/skills/as-assign-create/SKILL.md +23 -0
  57. data/handbook/skills/as-assign-drive/SKILL.md +24 -0
  58. data/handbook/skills/as-assign-prepare/SKILL.md +23 -0
  59. data/handbook/skills/as-assign-recover-fork/SKILL.md +22 -0
  60. data/handbook/skills/as-assign-run-in-batches/SKILL.md +23 -0
  61. data/handbook/skills/as-assign-start/SKILL.md +25 -0
  62. data/handbook/workflow-instructions/assign/compose.wf.md +256 -0
  63. data/handbook/workflow-instructions/assign/create.wf.md +215 -0
  64. data/handbook/workflow-instructions/assign/drive.wf.md +666 -0
  65. data/handbook/workflow-instructions/assign/prepare.wf.md +469 -0
  66. data/handbook/workflow-instructions/assign/recover-fork.wf.md +233 -0
  67. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +212 -0
  68. data/handbook/workflow-instructions/assign/start.wf.md +46 -0
  69. data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +173 -0
  70. data/lib/ace/assign/atoms/catalog_loader.rb +101 -0
  71. data/lib/ace/assign/atoms/composition_rules.rb +219 -0
  72. data/lib/ace/assign/atoms/number_generator.rb +110 -0
  73. data/lib/ace/assign/atoms/preset_expander.rb +277 -0
  74. data/lib/ace/assign/atoms/step_file_parser.rb +207 -0
  75. data/lib/ace/assign/atoms/step_numbering.rb +227 -0
  76. data/lib/ace/assign/atoms/step_sorter.rb +66 -0
  77. data/lib/ace/assign/atoms/tree_formatter.rb +106 -0
  78. data/lib/ace/assign/cli/commands/add.rb +102 -0
  79. data/lib/ace/assign/cli/commands/assignment_target.rb +55 -0
  80. data/lib/ace/assign/cli/commands/create.rb +63 -0
  81. data/lib/ace/assign/cli/commands/fail.rb +43 -0
  82. data/lib/ace/assign/cli/commands/finish.rb +88 -0
  83. data/lib/ace/assign/cli/commands/fork_run.rb +229 -0
  84. data/lib/ace/assign/cli/commands/list.rb +166 -0
  85. data/lib/ace/assign/cli/commands/retry_cmd.rb +42 -0
  86. data/lib/ace/assign/cli/commands/select.rb +45 -0
  87. data/lib/ace/assign/cli/commands/start.rb +40 -0
  88. data/lib/ace/assign/cli/commands/status.rb +407 -0
  89. data/lib/ace/assign/cli.rb +144 -0
  90. data/lib/ace/assign/models/assignment.rb +107 -0
  91. data/lib/ace/assign/models/assignment_info.rb +66 -0
  92. data/lib/ace/assign/models/queue_state.rb +326 -0
  93. data/lib/ace/assign/models/step.rb +197 -0
  94. data/lib/ace/assign/molecules/assignment_discoverer.rb +57 -0
  95. data/lib/ace/assign/molecules/assignment_manager.rb +276 -0
  96. data/lib/ace/assign/molecules/fork_session_launcher.rb +102 -0
  97. data/lib/ace/assign/molecules/queue_scanner.rb +130 -0
  98. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +376 -0
  99. data/lib/ace/assign/molecules/step_renumberer.rb +227 -0
  100. data/lib/ace/assign/molecules/step_writer.rb +246 -0
  101. data/lib/ace/assign/organisms/assignment_executor.rb +1299 -0
  102. data/lib/ace/assign/version.rb +7 -0
  103. data/lib/ace/assign.rb +141 -0
  104. metadata +289 -0
@@ -0,0 +1,212 @@
1
+ ---
2
+ doc-type: workflow
3
+ title: Run In Batches Workflow
4
+ purpose: workflow instruction for reusable repeated-item orchestration with deterministic assignment creation
5
+ ace-docs:
6
+ last-updated: 2026-03-18
7
+ last-checked: 2026-03-21
8
+ ---
9
+
10
+ # Run In Batches Workflow
11
+
12
+ ## Purpose
13
+
14
+ Create a reusable repeated-item assignment from:
15
+
16
+ 1. One instruction template
17
+ 2. One explicit `--items` list
18
+ 3. Optional execution modifiers (`--sequential`, `--max-parallel`, `--run`)
19
+
20
+ This workflow keeps creation deterministic:
21
+
22
+ 1. Render hidden spec under `.ace-local/assign/jobs/`
23
+ 2. Call `ace-assign create <hidden-spec-path>`
24
+ 3. Optionally hand off to `/as-assign-drive <assignment-id>`
25
+
26
+ ## Supported Inputs
27
+
28
+ ```bash
29
+ /as-assign-run-in-batches "Run E2E scenario {{item}}" --items TS-001,TS-002 --run
30
+ /as-assign-run-in-batches "Update docs for {{item}}" --items ace-assign,ace-task --sequential
31
+ /as-assign-run-in-batches "Review {{item}}" --items ace-git,ace-docs,ace-lint --max-parallel 3
32
+ ```
33
+
34
+ ## Runtime Boundary (Hard Rule)
35
+
36
+ `ace-assign create FILE` remains the deterministic runtime boundary.
37
+
38
+ - Parse and normalize workflow arguments in this workflow layer.
39
+ - Render a concrete hidden spec file.
40
+ - Pass the hidden spec file path to `ace-assign create`.
41
+ - Do not add natural-language parsing inside `ace-assign create`.
42
+
43
+ ## Process
44
+
45
+ ### 1. Parse Input
46
+
47
+ Required:
48
+ - One instruction template string (first positional argument)
49
+ - `--items <comma-separated-list>`
50
+
51
+ Optional:
52
+ - `--sequential` (run children one-by-one, still in fork context)
53
+ - `--max-parallel <N>` (parallel mode only, default: `3`; treated as rolling in-flight concurrency cap)
54
+ - `--run` (immediate handoff to `/as-assign-drive`)
55
+
56
+ If template or `--items` is missing, fail with an actionable message.
57
+
58
+ ### 2. Normalize and Validate Item List
59
+
60
+ Normalize `--items` as:
61
+
62
+ 1. Split by commas
63
+ 2. Trim whitespace around each item
64
+ 3. Drop empty entries
65
+ 4. Preserve original order
66
+
67
+ Validation:
68
+ - If normalized list is empty → fail
69
+ - If duplicates exist after normalization → fail
70
+ - If `--max-parallel` is provided and is not an integer >= 1 → fail
71
+
72
+ Example failure messages:
73
+ - `--items is required (example: --items TS-001,TS-002)`
74
+ - `--items produced no valid values after normalization`
75
+ - `--items contains duplicates after normalization: TS-002`
76
+
77
+ ### 3. Render Per-Item Instructions
78
+
79
+ For each normalized item:
80
+
81
+ - If template contains `{{item}}`, substitute with the item value.
82
+ - If template omits `{{item}}`, prepend a deterministic line:
83
+
84
+ ```text
85
+ Target item: <item>
86
+ ```
87
+
88
+ Then append the original template text unchanged.
89
+
90
+ ### 4. Render Hidden Spec
91
+
92
+ Create hidden spec directory if missing:
93
+
94
+ ```bash
95
+ mkdir -p .ace-local/assign/jobs
96
+ ```
97
+
98
+ Write hidden spec:
99
+
100
+ ```bash
101
+ .ace-local/assign/jobs/<timestamp>-run-in-batches.yml
102
+ ```
103
+
104
+ Minimal structure:
105
+
106
+ ```yaml
107
+ session:
108
+ name: run-in-batches-<timestamp>
109
+ description: Execute repeated-item assignment for explicit items.
110
+
111
+ steps:
112
+ - number: "010"
113
+ name: batch-items
114
+ batch_parent: true
115
+ parallel: true # false when --sequential is set
116
+ max_parallel: 3 # default 3 when parallel=true and flag omitted
117
+ fork_retry_limit: 1
118
+ instructions: |
119
+ Batch container for repeated-item execution.
120
+ Items: <item-1>, <item-2>, <item-3>
121
+ Scheduler: parallel=true, max_parallel=3.
122
+
123
+ - number: "010.01"
124
+ name: run-<item-1>
125
+ parent: "010"
126
+ context: fork
127
+ parallel: true # mirrors parent mode; false when --sequential
128
+ instructions: |
129
+ <rendered instruction for item-1>
130
+
131
+ - number: "010.02"
132
+ name: run-<item-2>
133
+ parent: "010"
134
+ context: fork
135
+ parallel: true # mirrors parent mode; false when --sequential
136
+ instructions: |
137
+ <rendered instruction for item-2>
138
+ ```
139
+
140
+ Rules:
141
+ - Always create one parent plus one child per item.
142
+ - A single item still creates a valid parent/child tree.
143
+ - Child steps always keep `context: fork` so every item is delegated in a forked environment.
144
+ - `--sequential` sets `parallel: false` and `max_parallel: 1` on parent/children.
145
+ - Parallel mode sets `parallel: true`; `max_parallel` is explicit or defaults to `3`.
146
+ - In parallel mode, `max_parallel` means maximum concurrent in-flight children; it is not a fixed wave size.
147
+ - Parent and child metadata are workflow-level scheduling hints consumed by `/as-assign-drive`.
148
+ - Each invocation writes a new hidden spec file.
149
+
150
+ ### 5. Create Assignment Deterministically
151
+
152
+ Invoke:
153
+
154
+ ```bash
155
+ ace-assign create .ace-local/assign/jobs/<timestamp>-run-in-batches.yml
156
+ ```
157
+
158
+ ### 6. Optional Immediate Handoff (`--run`)
159
+
160
+ If `--run` is present:
161
+
162
+ ```bash
163
+ /as-assign-drive <assignment-id>
164
+ ```
165
+
166
+ If no workable step is available, keep creation successful and report why drive did not continue.
167
+
168
+ ### 7. Report Result
169
+
170
+ Show:
171
+ - Assignment ID and name
172
+ - Assignment path
173
+ - Hidden spec provenance path
174
+ - Whether drive handoff ran
175
+
176
+ ## Error Handling
177
+
178
+ | Scenario | Action |
179
+ |----------|--------|
180
+ | Missing template | Fail with usage + example |
181
+ | Missing `--items` | Fail with actionable message |
182
+ | Empty normalized items | Fail; no assignment created |
183
+ | Duplicate normalized items | Fail; no assignment created |
184
+ | Invalid `--max-parallel` | Fail; no assignment created |
185
+ | Hidden-spec render failure | Fail with concrete error |
186
+ | `ace-assign create` rejection | Surface CLI error unchanged |
187
+ | `--run` requested but no workable step | Keep create success; report handoff reason |
188
+
189
+ ## Edge Cases
190
+
191
+ - Template omits `{{item}}` → prepend deterministic `Target item:` line.
192
+ - Single-item list still uses parent + one child.
193
+ - `--sequential` keeps fork context and only changes scheduler metadata.
194
+ - Item normalization preserves order of first occurrences.
195
+
196
+ ## Success Criteria
197
+
198
+ - `/as-assign-run-in-batches` accepts one template plus explicit `--items`
199
+ - Supports `--max-parallel` with default `3` when omitted in parallel mode
200
+ - Hidden spec is written under `.ace-local/assign/jobs/`
201
+ - Assignment has one parent plus one child step per item
202
+ - Child steps always include `context: fork`
203
+ - Parent/child metadata reflects scheduler intent (`parallel`, `max_parallel`, `fork_retry_limit`)
204
+ - `{{item}}` substitution and `Target item:` fallback are deterministic
205
+ - Optional `--run` handoff delegates to `/as-assign-drive`
206
+
207
+ ## Verification
208
+
209
+ ```bash
210
+ # Ensure new workflow exists and is discoverable
211
+ ace-bundle wfi://assign/run-in-batches
212
+ ```
@@ -0,0 +1,46 @@
1
+ ---
2
+ doc-type: workflow
3
+ title: Start Assignment Workflow (Legacy Compatibility)
4
+ purpose: preserve compatibility for as-assign-start while routing to public assign/create + assign/drive flow
5
+ ace-docs:
6
+ last-updated: 2026-03-18
7
+ last-checked: 2026-03-21
8
+ ---
9
+
10
+ # Start Assignment Workflow (Legacy Compatibility)
11
+
12
+ ## Purpose
13
+
14
+ `assign/start` is retained as an orchestration compatibility layer for typed canonical skill examples.
15
+ It delegates to create/drive flows that compose steps from assign-capable canonical skills.
16
+
17
+ Primary public UX remains:
18
+ - `/as-assign-create ...`
19
+ - `/as-assign-drive <assignment-id>`
20
+
21
+ ## Process
22
+
23
+ 1. Parse incoming arguments.
24
+ 2. If `--run` is provided, run create with run handoff enabled:
25
+
26
+ ```bash
27
+ /as-assign-create $ARGUMENTS --run
28
+ ```
29
+
30
+ 3. Otherwise, run create without immediate handoff:
31
+
32
+ ```bash
33
+ /as-assign-create $ARGUMENTS
34
+ ```
35
+
36
+ 4. If create succeeds and the caller requests explicit continuation, invoke:
37
+
38
+ ```bash
39
+ /as-assign-drive <assignment-id>
40
+ ```
41
+
42
+ ## Success Criteria
43
+
44
+ - Preserves compatibility entrypoint for orchestration examples.
45
+ - Delegates behavior to `assign/create` and `assign/drive`.
46
+ - Does not redefine the public assignment flow.
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Atoms
6
+ # Pure function: extracts and validates `assign:` block from markdown frontmatter.
7
+ #
8
+ # Takes a raw YAML frontmatter hash (already parsed from a .s.md or .wf.md file),
9
+ # extracts the `assign:` block, validates fields, and returns a structured result.
10
+ #
11
+ # No file I/O — reuses existing frontmatter extraction from StepFileParser or ace-support-markdown.
12
+ #
13
+ # @example
14
+ # frontmatter = { "id" => "v.0.9.0+task.148", "status" => "in-progress",
15
+ # "assign" => { "goal" => "implement-with-pr", "variables" => { "taskref" => "148" } } }
16
+ # result = AssignFrontmatterParser.parse(frontmatter)
17
+ # result[:config][:goal] # => "implement-with-pr"
18
+ # result[:valid] # => true
19
+ module AssignFrontmatterParser
20
+ VALID_FIELDS = %w[goal variables hints sub-steps context parent].freeze
21
+ VALID_HINT_ACTIONS = %w[include skip].freeze
22
+ VALID_CONTEXTS = %w[fork].freeze
23
+
24
+ # Parse and validate the assign: block from frontmatter.
25
+ #
26
+ # @param frontmatter [Hash] Full frontmatter hash from any .s.md or .wf.md file
27
+ # @return [Hash] { config: Hash|nil, valid: Boolean, errors: Array<String> }
28
+ def self.parse(frontmatter)
29
+ return {config: nil, valid: true, errors: []} if frontmatter.nil? || !frontmatter.is_a?(Hash)
30
+
31
+ assign_block = frontmatter["assign"]
32
+ return {config: nil, valid: true, errors: []} if assign_block.nil?
33
+
34
+ errors = validate(assign_block)
35
+ return {config: nil, valid: false, errors: errors} if errors.any?
36
+
37
+ config = extract_config(assign_block)
38
+ {config: config, valid: true, errors: []}
39
+ end
40
+
41
+ # Validate the assign block fields.
42
+ #
43
+ # @param assign_block [Hash] The assign: block from frontmatter
44
+ # @return [Array<String>] List of validation errors (empty if valid)
45
+ def self.validate(assign_block)
46
+ errors = []
47
+
48
+ unless assign_block.is_a?(Hash)
49
+ errors << "assign: must be a mapping (Hash), got #{assign_block.class}"
50
+ return errors
51
+ end
52
+
53
+ # Check for unknown fields
54
+ unknown = assign_block.keys - VALID_FIELDS
55
+ errors << "Unknown assign fields: #{unknown.join(", ")}" if unknown.any?
56
+
57
+ # Validate goal (string)
58
+ if assign_block.key?("goal") && !assign_block["goal"].is_a?(String)
59
+ errors << "assign.goal must be a string"
60
+ end
61
+
62
+ # Validate variables (hash)
63
+ if assign_block.key?("variables") && !assign_block["variables"].is_a?(Hash)
64
+ errors << "assign.variables must be a mapping (Hash)"
65
+ end
66
+
67
+ # Validate hints (array of hashes with include/skip keys)
68
+ if assign_block.key?("hints")
69
+ errors.concat(validate_hints(assign_block["hints"]))
70
+ end
71
+
72
+ # Validate sub-steps (array of strings)
73
+ if assign_block.key?("sub-steps")
74
+ if !assign_block["sub-steps"].is_a?(Array)
75
+ errors << "assign.sub-steps must be an array"
76
+ elsif assign_block["sub-steps"].any? { |s| !s.is_a?(String) }
77
+ errors << "assign.sub-steps entries must be strings"
78
+ end
79
+ end
80
+
81
+ # Validate context (string, must be in VALID_CONTEXTS)
82
+ if assign_block.key?("context")
83
+ ctx = assign_block["context"]
84
+ if !ctx.is_a?(String)
85
+ errors << "assign.context must be a string"
86
+ elsif !VALID_CONTEXTS.include?(ctx)
87
+ errors << "assign.context must be one of: #{VALID_CONTEXTS.join(", ")}"
88
+ end
89
+ end
90
+
91
+ # Validate parent (string)
92
+ if assign_block.key?("parent") && !assign_block["parent"].is_a?(String)
93
+ errors << "assign.parent must be a string"
94
+ end
95
+
96
+ errors
97
+ end
98
+ private_class_method :validate
99
+
100
+ # Extract structured config from a validated assign block.
101
+ #
102
+ # @param assign_block [Hash] Validated assign: block
103
+ # @return [Hash] Structured config with symbolized keys
104
+ def self.extract_config(assign_block)
105
+ {
106
+ goal: assign_block["goal"],
107
+ variables: assign_block["variables"] || {},
108
+ hints: normalize_hints(assign_block["hints"] || []),
109
+ sub_steps: assign_block["sub-steps"] || [],
110
+ context: assign_block["context"],
111
+ parent: assign_block["parent"]
112
+ }
113
+ end
114
+ private_class_method :extract_config
115
+
116
+ # Validate hints array entries.
117
+ #
118
+ # @param hints [Object] The hints value to validate
119
+ # @return [Array<String>] Validation errors
120
+ def self.validate_hints(hints)
121
+ errors = []
122
+
123
+ unless hints.is_a?(Array)
124
+ errors << "assign.hints must be an array"
125
+ return errors
126
+ end
127
+
128
+ hints.each_with_index do |hint, idx|
129
+ unless hint.is_a?(Hash)
130
+ errors << "assign.hints[#{idx}] must be a mapping (Hash)"
131
+ next
132
+ end
133
+
134
+ actions = hint.keys & VALID_HINT_ACTIONS
135
+ if actions.empty?
136
+ errors << "assign.hints[#{idx}] must have an 'include' or 'skip' key"
137
+ elsif actions.size > 1
138
+ errors << "assign.hints[#{idx}] cannot have both 'include' and 'skip'"
139
+ else
140
+ value = hint[actions.first]
141
+ unless value.is_a?(String)
142
+ errors << "assign.hints[#{idx}].#{actions.first} must be a string, got #{value.class}"
143
+ end
144
+ end
145
+
146
+ unknown_keys = hint.keys - VALID_HINT_ACTIONS
147
+ if unknown_keys.any?
148
+ errors << "assign.hints[#{idx}] has unknown keys: #{unknown_keys.join(", ")}"
149
+ end
150
+ end
151
+
152
+ errors
153
+ end
154
+ private_class_method :validate_hints
155
+
156
+ # Normalize hints into a consistent structure.
157
+ #
158
+ # @param hints [Array<Hash>] Raw hints from frontmatter
159
+ # @return [Array<Hash>] Normalized hints with :action and :step keys
160
+ def self.normalize_hints(hints)
161
+ hints.map do |hint|
162
+ if hint.key?("include")
163
+ {action: :include, step: hint["include"]}
164
+ elsif hint.key?("skip")
165
+ {action: :skip, step: hint["skip"]}
166
+ end
167
+ end.compact
168
+ end
169
+ private_class_method :normalize_hints
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Atoms
8
+ # Pure functions for loading and querying the step catalog.
9
+ #
10
+ # The step catalog is a directory of YAML files describing available
11
+ # step types, their prerequisites, artifacts, and metadata.
12
+ #
13
+ # @example Loading the catalog
14
+ # steps = CatalogLoader.load_all("/path/to/catalog/steps")
15
+ # # => [{ "name" => "onboard", "skill" => "onboard", ... }, ...]
16
+ #
17
+ # @example Finding a step
18
+ # step = CatalogLoader.find_by_name(steps, "work-on-task")
19
+ # # => { "name" => "work-on-task", "skill" => "ace:work-on-task", ... }
20
+ module CatalogLoader
21
+ # Load all step definitions from a catalog directory.
22
+ #
23
+ # @param steps_dir [String] Path to catalog/steps/ directory
24
+ # @return [Array<Hash>] Array of step definition hashes
25
+ def self.load_all(steps_dir)
26
+ return [] unless File.directory?(steps_dir)
27
+
28
+ Dir.glob(File.join(steps_dir, "*.step.yml")).sort.filter_map do |path|
29
+ parse_step_file(path)
30
+ end
31
+ end
32
+
33
+ # Find a step definition by name.
34
+ #
35
+ # @param steps [Array<Hash>] Loaded step definitions
36
+ # @param name [String] Step name to find
37
+ # @return [Hash, nil] Step definition or nil if not found
38
+ def self.find_by_name(steps, name)
39
+ steps.find { |p| p["name"] == name }
40
+ end
41
+
42
+ # Filter steps by tag.
43
+ #
44
+ # @param steps [Array<Hash>] Loaded step definitions
45
+ # @param tag [String] Tag to filter by
46
+ # @return [Array<Hash>] Steps matching the tag
47
+ def self.filter_by_tag(steps, tag)
48
+ steps.select { |p| (p["tags"] || []).include?(tag) }
49
+ end
50
+
51
+ # Find steps that produce a given artifact.
52
+ #
53
+ # @param steps [Array<Hash>] Loaded step definitions
54
+ # @param artifact [String] Artifact name (e.g., "code-changes")
55
+ # @return [Array<Hash>] Steps that produce the artifact
56
+ def self.producers_of(steps, artifact)
57
+ steps.select { |p| (p["produces"] || []).include?(artifact) }
58
+ end
59
+
60
+ # Validate that prerequisites are satisfied for a selection of steps.
61
+ #
62
+ # @param selected [Array<String>] Names of selected steps
63
+ # @param catalog [Array<Hash>] Full step catalog
64
+ # @return [Array<Hash>] Validation issues, each with :step, :prerequisite, :strength, :reason
65
+ def self.validate_prerequisites(selected, catalog)
66
+ issues = []
67
+
68
+ selected.each do |step_name|
69
+ step_def = find_by_name(catalog, step_name)
70
+ next unless step_def
71
+
72
+ (step_def["prerequisites"] || []).each do |prereq|
73
+ next if selected.include?(prereq["name"])
74
+
75
+ issues << {
76
+ step: step_name,
77
+ prerequisite: prereq["name"],
78
+ strength: prereq["strength"] || "recommended",
79
+ reason: prereq["reason"]
80
+ }
81
+ end
82
+ end
83
+
84
+ issues
85
+ end
86
+
87
+ # Parse a single step YAML file.
88
+ #
89
+ # @param path [String] File path
90
+ # @return [Hash, nil] Parsed step definition or nil on error
91
+ def self.parse_step_file(path)
92
+ YAML.safe_load_file(path, permitted_classes: [Date])
93
+ rescue => e
94
+ warn "Warning: Failed to parse step file #{path}: #{e.message}"
95
+ nil
96
+ end
97
+ private_class_method :parse_step_file
98
+ end
99
+ end
100
+ end
101
+ end