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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
  4. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
  6. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
  7. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -11
  8. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
  9. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
  10. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -16
  11. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
  12. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
  13. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
  14. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
  15. data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
  16. data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
  17. data/.ace-defaults/assign/presets/work-on-task.yml +3 -16
  18. data/CHANGELOG.md +201 -0
  19. data/README.md +20 -43
  20. data/docs/demo/canonical-skill-source.gif +0 -0
  21. data/docs/demo/canonical-skill-source.tape.yml +51 -0
  22. data/docs/demo/fork-provider.cast +957 -0
  23. data/docs/demo/fork-provider.gif +0 -0
  24. data/docs/demo/fork-provider.recording.json +32 -0
  25. data/docs/demo/fork-provider.tape.yml +65 -20
  26. data/docs/getting-started.md +5 -2
  27. data/docs/usage.md +47 -0
  28. data/handbook/guides/fork-context.g.md +2 -2
  29. data/handbook/skills/as-assign-drive/SKILL.md +13 -1
  30. data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
  31. data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
  32. data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
  33. data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
  34. data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
  35. data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
  36. data/handbook/workflow-instructions/assign/create.wf.md +6 -3
  37. data/handbook/workflow-instructions/assign/drive.wf.md +231 -14
  38. data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
  39. data/handbook/workflow-instructions/assign/prepare.wf.md +5 -5
  40. data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
  41. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
  42. data/handbook/workflow-instructions/assign/start.wf.md +5 -2
  43. data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
  44. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
  45. data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
  46. data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
  47. data/lib/ace/assign/cli/commands/assignment_target.rb +53 -0
  48. data/lib/ace/assign/cli/commands/finish.rb +7 -4
  49. data/lib/ace/assign/cli/commands/fork_run.rb +4 -1
  50. data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
  51. data/lib/ace/assign/cli/commands/start.rb +9 -3
  52. data/lib/ace/assign/cli/commands/status.rb +208 -227
  53. data/lib/ace/assign/cli/commands/step.rb +62 -0
  54. data/lib/ace/assign/cli.rb +8 -1
  55. data/lib/ace/assign/models/step.rb +4 -2
  56. data/lib/ace/assign/molecules/fork_session_launcher.rb +189 -8
  57. data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
  58. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
  59. data/lib/ace/assign/molecules/tmux_fork_runner.rb +191 -0
  60. data/lib/ace/assign/organisms/assignment_executor.rb +223 -24
  61. data/lib/ace/assign/version.rb +1 -1
  62. metadata +21 -5
  63. 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 assign.source URI from skill frontmatter (e.g., wfi://task/work)
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 = parse_frontmatter(File.read(skill_path))
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
- source = assign_block["source"]&.to_s&.strip
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 = parse_frontmatter(File.read(workflow_path))
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
- # - assign.source present and non-empty
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
- skill_index.keys.sort.filter do |skill_name|
75
- skill_path = skill_index[skill_name]
76
- frontmatter = parse_frontmatter(File.read(skill_path))
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
- validate_assign_source!(frontmatter["assign"], skill_name)
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.dig("assign", "source")&.to_s&.strip
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 = parse_frontmatter_and_body(File.read(skill_path))
130
- workflow_source = frontmatter.dig("assign", "source")&.to_s&.strip
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 ? parse_frontmatter_and_body(File.read(workflow_path)).last.to_s.strip : ""
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 = parse_frontmatter_and_body(File.read(workflow_path))
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 = parse_frontmatter(File.read(rendering["workflow_path"]))
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
- workflow_rendering = resolve_workflow_rendering(
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
- return entry.merge(workflow_rendering) if workflow_rendering
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
- @skill_index ||= begin
204
- index = {}
205
- skill_paths.each do |base_path|
206
- Dir.glob(File.join(base_path, "**", "SKILL.md")).sort.each do |path|
207
- frontmatter = parse_frontmatter(File.read(path))
208
- name = frontmatter["name"]&.to_s
209
- index[name] ||= path if name && !name.empty?
210
- rescue
211
- next
212
- end
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 validate_assign_source!(assign_block, skill_name)
261
- source = assign_block["source"]&.to_s&.strip
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 = parse_frontmatter(File.read(skill_path))
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
- validate_assign_source!(frontmatter["assign"], skill_name)
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 assign.source '#{uri}' for skill '#{skill_name}'. Supported: wfi://..."
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 assign.source '#{uri}' for skill '#{skill_name}'"
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
- return candidate if File.exist?(candidate)
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 assign.source '#{uri}' for skill '#{skill_name}'. Searched: #{searched}"
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