ace-assign 0.42.4 → 0.53.4
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 +4 -4
- data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -11
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -16
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
- data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
- data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task.yml +3 -16
- data/CHANGELOG.md +201 -0
- data/README.md +20 -43
- data/docs/demo/canonical-skill-source.gif +0 -0
- data/docs/demo/canonical-skill-source.tape.yml +51 -0
- data/docs/demo/fork-provider.cast +957 -0
- data/docs/demo/fork-provider.gif +0 -0
- data/docs/demo/fork-provider.recording.json +32 -0
- data/docs/demo/fork-provider.tape.yml +65 -20
- data/docs/getting-started.md +5 -2
- data/docs/usage.md +47 -0
- data/handbook/guides/fork-context.g.md +2 -2
- data/handbook/skills/as-assign-drive/SKILL.md +13 -1
- data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
- data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
- data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
- data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
- data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
- data/handbook/workflow-instructions/assign/create.wf.md +6 -3
- data/handbook/workflow-instructions/assign/drive.wf.md +231 -14
- data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
- data/handbook/workflow-instructions/assign/prepare.wf.md +5 -5
- data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
- data/handbook/workflow-instructions/assign/start.wf.md +5 -2
- data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
- data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +53 -0
- data/lib/ace/assign/cli/commands/finish.rb +7 -4
- data/lib/ace/assign/cli/commands/fork_run.rb +4 -1
- data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
- data/lib/ace/assign/cli/commands/start.rb +9 -3
- data/lib/ace/assign/cli/commands/status.rb +208 -227
- data/lib/ace/assign/cli/commands/step.rb +62 -0
- data/lib/ace/assign/cli.rb +8 -1
- data/lib/ace/assign/models/step.rb +4 -2
- data/lib/ace/assign/molecules/fork_session_launcher.rb +189 -8
- data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
- data/lib/ace/assign/molecules/tmux_fork_runner.rb +191 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +223 -24
- data/lib/ace/assign/version.rb +1 -1
- metadata +21 -5
- data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +0 -42
|
@@ -11,12 +11,44 @@ module Ace
|
|
|
11
11
|
#
|
|
12
12
|
# Flow:
|
|
13
13
|
# 1) Find SKILL.md by skill name (e.g., "ace-task-work")
|
|
14
|
-
# 2) Read
|
|
14
|
+
# 2) Read workflow binding from skill.execution.workflow (fallback: assign.source)
|
|
15
15
|
# 3) Resolve workflow file from URI
|
|
16
16
|
# 4) Parse workflow assign frontmatter (sub-steps/context)
|
|
17
17
|
class SkillAssignSourceResolver
|
|
18
18
|
ASSIGN_CAPABLE_KINDS = %w[workflow orchestration].freeze
|
|
19
19
|
|
|
20
|
+
class << self
|
|
21
|
+
def clear_caches!
|
|
22
|
+
@cache_store = {
|
|
23
|
+
frontmatter_and_body_cache: {},
|
|
24
|
+
skill_index_cache: {},
|
|
25
|
+
assign_capable_skill_names_cache: {},
|
|
26
|
+
assign_step_catalog_cache: {},
|
|
27
|
+
resolve_wfi_uri_cache: {}
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cache_store
|
|
32
|
+
@cache_store ||= {
|
|
33
|
+
frontmatter_and_body_cache: {},
|
|
34
|
+
skill_index_cache: {},
|
|
35
|
+
assign_capable_skill_names_cache: {},
|
|
36
|
+
assign_step_catalog_cache: {},
|
|
37
|
+
resolve_wfi_uri_cache: {}
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def cached_value(store_key, key)
|
|
44
|
+
cache_store[store_key][key]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def store_cached_value(store_key, key, value)
|
|
48
|
+
cache_store[store_key][key] = value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
20
52
|
def initialize(project_root: nil, skill_paths: nil, workflow_paths: nil)
|
|
21
53
|
@project_root = project_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
22
54
|
configured_skill_paths = skill_paths || Ace::Assign.config["skill_source_paths"]
|
|
@@ -29,6 +61,24 @@ module Ace
|
|
|
29
61
|
@skill_paths = (canonical_paths + override_paths).uniq
|
|
30
62
|
@workflow_paths = (canonical_workflow_paths + configured_workflow_paths).uniq
|
|
31
63
|
@skill_index = nil
|
|
64
|
+
@cache_signature = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def clear_caches!
|
|
68
|
+
@skill_index = nil
|
|
69
|
+
@assign_capable_skill_names = nil
|
|
70
|
+
@assign_step_catalog = nil
|
|
71
|
+
@cache_signature = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cache_signature
|
|
75
|
+
@cache_signature ||= begin
|
|
76
|
+
[
|
|
77
|
+
project_root,
|
|
78
|
+
skill_path_signature,
|
|
79
|
+
workflow_path_signature
|
|
80
|
+
].join("|")
|
|
81
|
+
end
|
|
32
82
|
end
|
|
33
83
|
|
|
34
84
|
# Resolve assign config for a skill.
|
|
@@ -40,19 +90,17 @@ module Ace
|
|
|
40
90
|
skill_path = skill_index[skill_name] || find_skill_by_convention(skill_name)
|
|
41
91
|
return nil unless skill_path
|
|
42
92
|
|
|
43
|
-
skill_frontmatter =
|
|
44
|
-
if assign_capable_skill_frontmatter?(skill_frontmatter) && skill_frontmatter["assign"]
|
|
45
|
-
validate_assign_source!(skill_frontmatter["assign"], skill_name)
|
|
46
|
-
end
|
|
47
|
-
|
|
93
|
+
skill_frontmatter = cached_parse_frontmatter_from_file(skill_path)
|
|
48
94
|
assign_block = skill_frontmatter["assign"]
|
|
49
95
|
return nil unless assign_block.is_a?(Hash)
|
|
50
96
|
|
|
51
|
-
|
|
97
|
+
validate_workflow_binding!(skill_frontmatter, skill_name) if assign_capable_skill_frontmatter?(skill_frontmatter)
|
|
98
|
+
|
|
99
|
+
source = workflow_binding_for_skill_frontmatter(skill_frontmatter)
|
|
52
100
|
return nil if source.nil? || source.empty?
|
|
53
101
|
|
|
54
102
|
workflow_path = resolve_source_uri(source, skill_name)
|
|
55
|
-
workflow_frontmatter =
|
|
103
|
+
workflow_frontmatter = cached_parse_frontmatter_from_file(workflow_path)
|
|
56
104
|
parsed = Atoms::AssignFrontmatterParser.parse(workflow_frontmatter)
|
|
57
105
|
|
|
58
106
|
unless parsed[:valid]
|
|
@@ -66,20 +114,26 @@ module Ace
|
|
|
66
114
|
#
|
|
67
115
|
# Assign-capable skills are canonical skills with:
|
|
68
116
|
# - skill.kind: workflow|orchestration
|
|
69
|
-
# -
|
|
117
|
+
# - workflow binding present (skill.execution.workflow or assign.source)
|
|
70
118
|
#
|
|
71
119
|
# @return [Array<String>] Skill names
|
|
72
120
|
# @raise [Ace::Assign::Error] When assign-capable skill has invalid assign metadata
|
|
73
121
|
def assign_capable_skill_names
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
122
|
+
cached = self.class.send(:cached_value, :assign_capable_skill_names_cache, cache_signature)
|
|
123
|
+
return cached if cached
|
|
124
|
+
|
|
125
|
+
names = skill_index.keys.sort.filter do |skill_name|
|
|
126
|
+
frontmatter = skill_index_frontmatter(skill_name)
|
|
77
127
|
next false unless assign_capable_skill_frontmatter?(frontmatter)
|
|
128
|
+
next false unless public_discovery_skill_frontmatter?(frontmatter)
|
|
78
129
|
next false unless frontmatter["assign"].is_a?(Hash)
|
|
79
130
|
|
|
80
|
-
|
|
131
|
+
validate_workflow_binding!(frontmatter, skill_name)
|
|
81
132
|
true
|
|
82
133
|
end
|
|
134
|
+
|
|
135
|
+
self.class.send(:store_cached_value, :assign_capable_skill_names_cache, cache_signature, names)
|
|
136
|
+
names
|
|
83
137
|
end
|
|
84
138
|
|
|
85
139
|
# Build assignment step entries from canonical skills.
|
|
@@ -90,12 +144,14 @@ module Ace
|
|
|
90
144
|
#
|
|
91
145
|
# @return [Array<Hash>] Step definitions keyed by canonical precedence
|
|
92
146
|
def assign_step_catalog
|
|
147
|
+
cached = self.class.send(:cached_value, :assign_step_catalog_cache, cache_signature)
|
|
148
|
+
return cached if cached
|
|
149
|
+
|
|
93
150
|
catalog = {}
|
|
94
151
|
|
|
95
152
|
each_assign_capable_skill do |skill_name, frontmatter|
|
|
96
153
|
steps = frontmatter.dig("assign", "steps")
|
|
97
|
-
workflow_source = frontmatter
|
|
98
|
-
workflow_source = frontmatter.dig("skill", "execution", "workflow")&.to_s&.strip if workflow_source.nil? || workflow_source.empty?
|
|
154
|
+
workflow_source = workflow_binding_for_skill_frontmatter(frontmatter)
|
|
99
155
|
next unless steps.is_a?(Array)
|
|
100
156
|
|
|
101
157
|
steps.each do |step|
|
|
@@ -107,6 +163,7 @@ module Ace
|
|
|
107
163
|
|
|
108
164
|
entry = step.dup
|
|
109
165
|
entry["name"] = step_name
|
|
166
|
+
entry["source"] = "skill://#{skill_name}"
|
|
110
167
|
entry["skill"] = skill_name
|
|
111
168
|
entry["source_skill"] = skill_name
|
|
112
169
|
entry["workflow"] = workflow_source if workflow_source && !workflow_source.empty?
|
|
@@ -115,7 +172,9 @@ module Ace
|
|
|
115
172
|
end
|
|
116
173
|
end
|
|
117
174
|
|
|
118
|
-
catalog.values
|
|
175
|
+
catalog_values = catalog.values
|
|
176
|
+
self.class.send(:store_cached_value, :assign_step_catalog_cache, cache_signature, catalog_values)
|
|
177
|
+
catalog_values
|
|
119
178
|
end
|
|
120
179
|
|
|
121
180
|
# Resolve canonical skill rendering details for a skill-backed step.
|
|
@@ -126,18 +185,16 @@ module Ace
|
|
|
126
185
|
skill_path = skill_index[skill_name] || find_skill_by_convention(skill_name)
|
|
127
186
|
return nil unless skill_path
|
|
128
187
|
|
|
129
|
-
frontmatter, skill_body =
|
|
130
|
-
workflow_source = frontmatter
|
|
131
|
-
if workflow_source.nil? || workflow_source.empty?
|
|
132
|
-
workflow_source = frontmatter.dig("skill", "execution", "workflow")&.to_s&.strip
|
|
133
|
-
end
|
|
188
|
+
frontmatter, skill_body = cached_parse_frontmatter_and_body_from_file(skill_path)
|
|
189
|
+
workflow_source = workflow_binding_for_skill_frontmatter(frontmatter)
|
|
134
190
|
|
|
135
191
|
workflow_path = resolve_workflow_path(workflow_source, skill_name)
|
|
136
|
-
workflow_body = workflow_path ?
|
|
192
|
+
workflow_body = workflow_path ? cached_parse_frontmatter_and_body_from_file(workflow_path).last.to_s.strip : ""
|
|
137
193
|
|
|
138
194
|
{
|
|
139
195
|
"name" => frontmatter["name"] || skill_name,
|
|
140
196
|
"description" => frontmatter["description"],
|
|
197
|
+
"source" => "skill://#{skill_name}",
|
|
141
198
|
"skill" => skill_name,
|
|
142
199
|
"workflow" => workflow_source,
|
|
143
200
|
"workflow_path" => workflow_path,
|
|
@@ -152,10 +209,11 @@ module Ace
|
|
|
152
209
|
workflow_path = resolve_workflow_path(workflow_ref, step_name || source_skill || workflow_ref)
|
|
153
210
|
return nil unless workflow_path
|
|
154
211
|
|
|
155
|
-
frontmatter, body =
|
|
212
|
+
frontmatter, body = cached_parse_frontmatter_and_body_from_file(workflow_path)
|
|
156
213
|
{
|
|
157
214
|
"name" => step_name || frontmatter["name"],
|
|
158
215
|
"description" => frontmatter["description"],
|
|
216
|
+
"source" => workflow_ref,
|
|
159
217
|
"workflow" => workflow_ref,
|
|
160
218
|
"workflow_path" => workflow_path,
|
|
161
219
|
"source_skill" => source_skill,
|
|
@@ -163,17 +221,57 @@ module Ace
|
|
|
163
221
|
}
|
|
164
222
|
end
|
|
165
223
|
|
|
224
|
+
# Resolve rendering details from canonical source reference.
|
|
225
|
+
#
|
|
226
|
+
# @param source [String]
|
|
227
|
+
# @return [Hash, nil]
|
|
228
|
+
def resolve_source_rendering(source, step_name: nil, source_skill: nil)
|
|
229
|
+
source_ref = source&.to_s&.strip
|
|
230
|
+
return nil if source_ref.nil? || source_ref.empty?
|
|
231
|
+
|
|
232
|
+
if source_ref.start_with?("skill://")
|
|
233
|
+
skill_name = source_ref.delete_prefix("skill://").strip
|
|
234
|
+
return nil if skill_name.empty?
|
|
235
|
+
|
|
236
|
+
return resolve_skill_rendering(skill_name)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
return resolve_workflow_rendering(source_ref, step_name: step_name, source_skill: source_skill) if source_ref.start_with?("wfi://")
|
|
240
|
+
|
|
241
|
+
raise Error, "Unsupported source '#{source_ref}'. Supported: skill://..., wfi://..."
|
|
242
|
+
end
|
|
243
|
+
|
|
166
244
|
def resolve_workflow_assign_config(workflow_source, step_name: nil, source_skill: nil)
|
|
167
245
|
rendering = resolve_workflow_rendering(workflow_source, step_name: step_name, source_skill: source_skill)
|
|
168
246
|
return nil unless rendering && rendering["workflow_path"]
|
|
169
247
|
|
|
170
|
-
workflow_frontmatter =
|
|
248
|
+
workflow_frontmatter = cached_parse_frontmatter_from_file(rendering["workflow_path"])
|
|
171
249
|
parsed = Atoms::AssignFrontmatterParser.parse(workflow_frontmatter)
|
|
172
250
|
return nil unless parsed[:valid]
|
|
173
251
|
|
|
174
252
|
parsed[:config]
|
|
175
253
|
end
|
|
176
254
|
|
|
255
|
+
# Resolve assign config from canonical source reference.
|
|
256
|
+
#
|
|
257
|
+
# @param source [String]
|
|
258
|
+
# @return [Hash, nil]
|
|
259
|
+
def resolve_source_assign_config(source, step_name: nil, source_skill: nil)
|
|
260
|
+
source_ref = source&.to_s&.strip
|
|
261
|
+
return nil if source_ref.nil? || source_ref.empty?
|
|
262
|
+
|
|
263
|
+
if source_ref.start_with?("skill://")
|
|
264
|
+
skill_name = source_ref.delete_prefix("skill://").strip
|
|
265
|
+
return nil if skill_name.empty?
|
|
266
|
+
|
|
267
|
+
return resolve_assign_config(skill_name)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
return resolve_workflow_assign_config(source_ref, step_name: step_name, source_skill: source_skill) if source_ref.start_with?("wfi://")
|
|
271
|
+
|
|
272
|
+
raise Error, "Unsupported source '#{source_ref}'. Supported: skill://..., wfi://..."
|
|
273
|
+
end
|
|
274
|
+
|
|
177
275
|
# Resolve canonical rendering details for a public step name.
|
|
178
276
|
#
|
|
179
277
|
# @param step_name [String]
|
|
@@ -182,12 +280,17 @@ module Ace
|
|
|
182
280
|
entry = assign_step_catalog.find { |step| step["name"] == step_name }
|
|
183
281
|
return nil unless entry
|
|
184
282
|
|
|
185
|
-
|
|
186
|
-
entry["workflow"],
|
|
283
|
+
source_rendering = resolve_source_rendering(
|
|
284
|
+
entry["source"] || entry["workflow"],
|
|
187
285
|
step_name: entry["name"],
|
|
188
286
|
source_skill: entry["source_skill"] || entry["skill"]
|
|
189
287
|
)
|
|
190
|
-
|
|
288
|
+
if source_rendering
|
|
289
|
+
merged = entry.merge(source_rendering)
|
|
290
|
+
merged["name"] = entry["name"] if entry["name"]
|
|
291
|
+
merged["description"] = entry["description"] if entry["description"]
|
|
292
|
+
return merged
|
|
293
|
+
end
|
|
191
294
|
|
|
192
295
|
rendering = resolve_skill_rendering(entry["skill"])
|
|
193
296
|
return nil unless rendering
|
|
@@ -200,19 +303,22 @@ module Ace
|
|
|
200
303
|
attr_reader :project_root, :skill_paths, :workflow_paths
|
|
201
304
|
|
|
202
305
|
def skill_index
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
306
|
+
cached = self.class.send(:cached_value, :skill_index_cache, cache_signature)
|
|
307
|
+
return cached if cached
|
|
308
|
+
|
|
309
|
+
index = {}
|
|
310
|
+
skill_paths.each do |base_path|
|
|
311
|
+
Dir.glob(File.join(base_path, "**", "SKILL.md")).sort.each do |path|
|
|
312
|
+
frontmatter = cached_parse_frontmatter_from_file(path)
|
|
313
|
+
name = frontmatter["name"]&.to_s
|
|
314
|
+
index[name] ||= path if name && !name.empty?
|
|
315
|
+
rescue
|
|
316
|
+
next
|
|
213
317
|
end
|
|
214
|
-
index
|
|
215
318
|
end
|
|
319
|
+
|
|
320
|
+
self.class.send(:store_cached_value, :skill_index_cache, cache_signature, index)
|
|
321
|
+
index
|
|
216
322
|
end
|
|
217
323
|
|
|
218
324
|
def normalize_paths(paths)
|
|
@@ -252,15 +358,51 @@ module Ace
|
|
|
252
358
|
[frontmatter, body_lines.join]
|
|
253
359
|
end
|
|
254
360
|
|
|
361
|
+
def cached_parse_frontmatter_from_file(path)
|
|
362
|
+
cached_parse_frontmatter_and_body_from_file(path).first
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def cached_parse_frontmatter_and_body_from_file(path)
|
|
366
|
+
cache_key = cache_file_signature(path)
|
|
367
|
+
cached = self.class.send(:cached_value, :frontmatter_and_body_cache, path)
|
|
368
|
+
return cached[:value] if cached && cached[:signature] == cache_key
|
|
369
|
+
|
|
370
|
+
parsed = parse_frontmatter_and_body(File.read(path))
|
|
371
|
+
payload = {signature: cache_key, value: parsed}
|
|
372
|
+
self.class.send(:store_cached_value, :frontmatter_and_body_cache, path, payload)
|
|
373
|
+
parsed
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def cache_file_signature(path)
|
|
377
|
+
stat = File.stat(path)
|
|
378
|
+
"#{stat.mtime.to_f}:#{stat.size}"
|
|
379
|
+
rescue
|
|
380
|
+
"missing"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def skill_index_frontmatter(skill_name)
|
|
384
|
+
skill_path = skill_index[skill_name]
|
|
385
|
+
return {} unless skill_path
|
|
386
|
+
|
|
387
|
+
cached_parse_frontmatter_from_file(skill_path)
|
|
388
|
+
end
|
|
389
|
+
|
|
255
390
|
def assign_capable_skill_frontmatter?(frontmatter)
|
|
256
391
|
kind = frontmatter.dig("skill", "kind")&.to_s
|
|
257
392
|
ASSIGN_CAPABLE_KINDS.include?(kind)
|
|
258
393
|
end
|
|
259
394
|
|
|
260
|
-
def
|
|
261
|
-
source =
|
|
395
|
+
def workflow_binding_for_skill_frontmatter(frontmatter)
|
|
396
|
+
source = frontmatter.dig("skill", "execution", "workflow")&.to_s&.strip
|
|
397
|
+
return source unless source.nil? || source.empty?
|
|
398
|
+
|
|
399
|
+
frontmatter.dig("assign", "source")&.to_s&.strip
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def validate_workflow_binding!(frontmatter, skill_name)
|
|
403
|
+
source = workflow_binding_for_skill_frontmatter(frontmatter)
|
|
262
404
|
if source.nil? || source.empty?
|
|
263
|
-
raise Error, "Missing assign.source for assign-capable skill '#{skill_name}'"
|
|
405
|
+
raise Error, "Missing workflow binding (skill.execution.workflow or assign.source) for assign-capable skill '#{skill_name}'"
|
|
264
406
|
end
|
|
265
407
|
end
|
|
266
408
|
|
|
@@ -273,15 +415,20 @@ module Ace
|
|
|
273
415
|
|
|
274
416
|
def each_assign_capable_skill
|
|
275
417
|
skill_index.each do |skill_name, skill_path|
|
|
276
|
-
frontmatter =
|
|
418
|
+
frontmatter = cached_parse_frontmatter_from_file(skill_path)
|
|
277
419
|
next unless assign_capable_skill_frontmatter?(frontmatter)
|
|
420
|
+
next unless public_discovery_skill_frontmatter?(frontmatter)
|
|
278
421
|
next unless frontmatter["assign"].is_a?(Hash)
|
|
279
422
|
|
|
280
|
-
|
|
423
|
+
validate_workflow_binding!(frontmatter, skill_name)
|
|
281
424
|
yield skill_name, frontmatter
|
|
282
425
|
end
|
|
283
426
|
end
|
|
284
427
|
|
|
428
|
+
def public_discovery_skill_frontmatter?(frontmatter)
|
|
429
|
+
frontmatter["user-invocable"] == true
|
|
430
|
+
end
|
|
431
|
+
|
|
285
432
|
def discover_canonical_skill_source_paths
|
|
286
433
|
discover_protocol_source_paths(
|
|
287
434
|
protocol: "skill",
|
|
@@ -315,6 +462,24 @@ module Ace
|
|
|
315
462
|
(registry_paths + discover_package_default_source_paths(package_glob)).uniq
|
|
316
463
|
end
|
|
317
464
|
|
|
465
|
+
def skill_path_signature
|
|
466
|
+
@skill_path_signature ||= source_path_signature(skill_paths, "**/SKILL.md")
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def workflow_path_signature
|
|
470
|
+
@workflow_path_signature ||= source_path_signature(workflow_paths, "**/*.wf.md")
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def source_path_signature(base_paths, glob_pattern)
|
|
474
|
+
files = base_paths.flat_map do |base_path|
|
|
475
|
+
next [] unless File.directory?(base_path)
|
|
476
|
+
|
|
477
|
+
Dir.glob(File.join(base_path, glob_pattern)).sort
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
files.sort.map { |path| "#{path}:#{cache_file_signature(path)}" }.join("|")
|
|
481
|
+
end
|
|
482
|
+
|
|
318
483
|
def with_project_root
|
|
319
484
|
Dir.chdir(project_root) { yield }
|
|
320
485
|
rescue Errno::ENOENT
|
|
@@ -371,23 +536,34 @@ module Ace
|
|
|
371
536
|
if uri.start_with?("wfi://")
|
|
372
537
|
resolve_wfi_uri(uri, skill_name)
|
|
373
538
|
else
|
|
374
|
-
raise Error, "Unsupported
|
|
539
|
+
raise Error, "Unsupported workflow binding '#{uri}' for skill '#{skill_name}'. Supported: wfi://..."
|
|
375
540
|
end
|
|
376
541
|
end
|
|
377
542
|
|
|
378
543
|
def resolve_wfi_uri(uri, skill_name)
|
|
544
|
+
cache_key = [
|
|
545
|
+
uri,
|
|
546
|
+
skill_name,
|
|
547
|
+
cache_signature
|
|
548
|
+
].join("|")
|
|
549
|
+
cached = self.class.send(:cached_value, :resolve_wfi_uri_cache, cache_key)
|
|
550
|
+
return cached if cached
|
|
551
|
+
|
|
379
552
|
workflow_name = uri.delete_prefix("wfi://").strip
|
|
380
553
|
if workflow_name.empty?
|
|
381
|
-
raise Error, "Empty workflow name in
|
|
554
|
+
raise Error, "Empty workflow name in workflow binding '#{uri}' for skill '#{skill_name}'"
|
|
382
555
|
end
|
|
383
556
|
|
|
384
557
|
workflow_paths.each do |base_path|
|
|
385
558
|
candidate = File.join(base_path, "#{workflow_name}.wf.md")
|
|
386
|
-
|
|
559
|
+
if File.exist?(candidate)
|
|
560
|
+
self.class.send(:store_cached_value, :resolve_wfi_uri_cache, cache_key, candidate)
|
|
561
|
+
return candidate
|
|
562
|
+
end
|
|
387
563
|
end
|
|
388
564
|
|
|
389
565
|
searched = workflow_paths.join(", ")
|
|
390
|
-
raise Error, "Could not resolve
|
|
566
|
+
raise Error, "Could not resolve workflow binding '#{uri}' for skill '#{skill_name}'. Searched: #{searched}"
|
|
391
567
|
end
|
|
392
568
|
end
|
|
393
569
|
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Assign
|
|
10
|
+
module Molecules
|
|
11
|
+
# Minimal tmux integration for forked subtree execution.
|
|
12
|
+
class TmuxForkRunner
|
|
13
|
+
DEFAULT_POLL_INTERVAL = 0.2
|
|
14
|
+
|
|
15
|
+
def initialize(tmux_binary: "tmux")
|
|
16
|
+
@tmux_binary = tmux_binary
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def tmux_context?
|
|
20
|
+
!current_session.to_s.strip.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def current_session
|
|
24
|
+
explicit = ENV["ACE_TMUX_SESSION"].to_s.strip
|
|
25
|
+
return explicit unless explicit.empty?
|
|
26
|
+
return nil if ENV["TMUX"].to_s.strip.empty?
|
|
27
|
+
|
|
28
|
+
capture([tmux_binary, "display-message", "-p", "#S"]).stdout
|
|
29
|
+
rescue
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def current_window
|
|
34
|
+
explicit = ENV["ACE_ASSIGN_FORK_WINDOW"].to_s.strip
|
|
35
|
+
return explicit unless explicit.empty?
|
|
36
|
+
session = ENV["ACE_TMUX_SESSION"].to_s.strip
|
|
37
|
+
if !session.empty?
|
|
38
|
+
window = capture([tmux_binary, "display-message", "-t", "#{session}:", "-p", "#W"]).stdout
|
|
39
|
+
return window unless window.empty?
|
|
40
|
+
|
|
41
|
+
return active_window_name(session)
|
|
42
|
+
end
|
|
43
|
+
return nil if ENV["TMUX"].to_s.strip.empty?
|
|
44
|
+
|
|
45
|
+
capture([tmux_binary, "display-message", "-p", "#W"]).stdout
|
|
46
|
+
rescue
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fork_window_name(base_window)
|
|
51
|
+
base = base_window.to_s.strip
|
|
52
|
+
return base if base.end_with?("-fs")
|
|
53
|
+
|
|
54
|
+
"#{base}-fs"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ensure_window(session:, name:, root:)
|
|
58
|
+
return {created: false, target: "#{session}:#{name}"} if window_exists?(session: session, name: name)
|
|
59
|
+
|
|
60
|
+
result = capture([tmux_binary, "new-window", "-t", "#{session}:", "-n", name, "-c", File.expand_path(root)])
|
|
61
|
+
raise Error, "Failed to create tmux fork window #{name}: #{result.stderr}" unless result.success?
|
|
62
|
+
|
|
63
|
+
{created: true, target: "#{session}:#{name}"}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def prepare_pane(session:, window:, root:, keep_existing:)
|
|
67
|
+
target = "#{session}:#{window}"
|
|
68
|
+
if keep_existing
|
|
69
|
+
pane = first_pane(target)
|
|
70
|
+
set_pane_remain_on_exit(pane)
|
|
71
|
+
select_layout(target)
|
|
72
|
+
return pane
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
result = capture([tmux_binary, "split-window", "-t", target, "-c", File.expand_path(root), "-P", "-F", '#{pane_id}'])
|
|
76
|
+
raise Error, "Failed to create tmux fork pane in #{window}: #{result.stderr}" unless result.success?
|
|
77
|
+
|
|
78
|
+
pane = result.stdout
|
|
79
|
+
set_pane_remain_on_exit(pane)
|
|
80
|
+
select_layout(target)
|
|
81
|
+
pane
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def select_window(session:, window:)
|
|
85
|
+
run!([tmux_binary, "select-window", "-t", "#{session}:#{window}"], "select tmux fork window #{window}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def run_script_in_pane(pane_target:, script_path:)
|
|
89
|
+
command = "bash #{Shellwords.escape(File.expand_path(script_path))}"
|
|
90
|
+
run!([tmux_binary, "send-keys", "-t", pane_target, command, "Enter"], "send tmux fork command")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def wait_for_completion(status_file:, timeout:)
|
|
94
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_i
|
|
95
|
+
until File.exist?(status_file)
|
|
96
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
97
|
+
raise Error, "Timed out waiting for tmux fork pane to finish (#{File.basename(status_file)})"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sleep(DEFAULT_POLL_INTERVAL)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
status = File.read(status_file).strip
|
|
104
|
+
Integer(status)
|
|
105
|
+
rescue ArgumentError
|
|
106
|
+
raise Error, "Invalid tmux fork status file: #{status_file}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def merge_tmux_metadata(session_meta_file:, session:, window:, pane:)
|
|
110
|
+
data = if File.exist?(session_meta_file)
|
|
111
|
+
YAML.safe_load_file(session_meta_file) || {}
|
|
112
|
+
else
|
|
113
|
+
{}
|
|
114
|
+
end
|
|
115
|
+
data["launch_mode"] = "tmux"
|
|
116
|
+
data["tmux_session"] = session
|
|
117
|
+
data["tmux_window"] = window
|
|
118
|
+
data["tmux_pane_id"] = pane
|
|
119
|
+
File.write(session_meta_file, data.to_yaml)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
attr_reader :tmux_binary
|
|
125
|
+
|
|
126
|
+
def window_exists?(session:, name:)
|
|
127
|
+
result = capture([tmux_binary, "list-windows", "-t", session, "-F", '#{window_name}'])
|
|
128
|
+
return false unless result.success?
|
|
129
|
+
|
|
130
|
+
result.stdout_lines.any? { |value| value == name }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def active_window_name(session)
|
|
134
|
+
result = capture([tmux_binary, "list-windows", "-t", session, "-F", '#{window_active} #{window_name}'])
|
|
135
|
+
return nil unless result.success?
|
|
136
|
+
|
|
137
|
+
active = result.stdout_lines.find { |line| line.start_with?("1 ") }
|
|
138
|
+
active&.sub(/\A1\s+/, "") || result.stdout_lines.first&.sub(/\A[01]\s+/, "")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def first_pane(target)
|
|
142
|
+
result = capture([tmux_binary, "list-panes", "-t", target, "-F", '#{pane_id}'])
|
|
143
|
+
raise Error, "Failed to inspect panes for #{target}: #{result.stderr}" unless result.success?
|
|
144
|
+
|
|
145
|
+
pane = result.stdout_lines.first
|
|
146
|
+
raise Error, "No panes found for #{target}" if pane.to_s.empty?
|
|
147
|
+
|
|
148
|
+
pane
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def set_pane_remain_on_exit(pane_target)
|
|
152
|
+
run!([tmux_binary, "set-option", "-p", "-t", pane_target, "remain-on-exit", "on"], "enable remain-on-exit")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def select_layout(target)
|
|
156
|
+
run!([tmux_binary, "select-layout", "-t", target, "tiled"], "apply tiled layout")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run!(cmd, action)
|
|
160
|
+
result = capture(cmd)
|
|
161
|
+
return if result.success?
|
|
162
|
+
|
|
163
|
+
raise Error, "Failed to #{action}: #{result.stderr}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def capture(cmd)
|
|
167
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
168
|
+
Result.new(stdout: stdout, stderr: stderr, status: status)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
Result = Struct.new(:stdout, :stderr, :status, keyword_init: true) do
|
|
172
|
+
def success?
|
|
173
|
+
status.success?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def stdout
|
|
177
|
+
self[:stdout].to_s.strip
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def stderr
|
|
181
|
+
self[:stderr].to_s.strip
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def stdout_lines
|
|
185
|
+
stdout.split("\n").map(&:strip).reject(&:empty?)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|