ace-design 0.2.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 (111) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/design/config.yml +9 -0
  3. data/.ace-defaults/design/contexts/app/component.md +3 -0
  4. data/.ace-defaults/design/contexts/app/view.md +3 -0
  5. data/.ace-defaults/design/contexts/app.md +3 -0
  6. data/.ace-defaults/design/contexts/project.md +7 -0
  7. data/.ace-defaults/design/contexts/site/component.md +3 -0
  8. data/.ace-defaults/design/contexts/site/view.md +3 -0
  9. data/.ace-defaults/design/contexts/site.md +3 -0
  10. data/.ace-defaults/design/presets/ace-vs-taste.yml +37 -0
  11. data/.ace-defaults/design/presets/fast.yml +18 -0
  12. data/.ace-defaults/design/presets/impeccable.yml +48 -0
  13. data/.ace-defaults/design/presets/superdesign.yml +48 -0
  14. data/.ace-defaults/design/presets/taste-v2.yml +46 -0
  15. data/.ace-defaults/design/presets/ui-ux-pro-max.yml +48 -0
  16. data/.ace-defaults/nav/protocols/guide-sources/ace-design.yml +8 -0
  17. data/.ace-defaults/nav/protocols/skill-sources/ace-design.yml +8 -0
  18. data/.ace-defaults/nav/protocols/tmpl-sources/ace-design.yml +8 -0
  19. data/.ace-defaults/nav/protocols/wfi-sources/ace-design.yml +8 -0
  20. data/CHANGELOG.md +37 -0
  21. data/LICENSE +21 -0
  22. data/README.md +119 -0
  23. data/Rakefile +12 -0
  24. data/browser/dependencies.mjs +20 -0
  25. data/browser/doctor.mjs +7 -0
  26. data/browser/inspect.mjs +61 -0
  27. data/browser/package-lock.json +71 -0
  28. data/browser/package.json +1 -0
  29. data/browser/preview.mjs +35 -0
  30. data/browser/source.mjs +14 -0
  31. data/docs/contexts.md +66 -0
  32. data/docs/inspiration.md +28 -0
  33. data/docs/intent-verification.md +73 -0
  34. data/docs/runner.md +108 -0
  35. data/docs/testing.md +18 -0
  36. data/docs/usage.md +38 -0
  37. data/exe/ace-design +19 -0
  38. data/handbook/benchmarks/app-booking/brief.md +8 -0
  39. data/handbook/benchmarks/app-booking/fixtures.json +46 -0
  40. data/handbook/benchmarks/app-inventory/brief.md +8 -0
  41. data/handbook/benchmarks/app-inventory/fixtures.json +38 -0
  42. data/handbook/benchmarks/app-support/brief.md +8 -0
  43. data/handbook/benchmarks/app-support/fixtures.json +41 -0
  44. data/handbook/benchmarks/site-community/brief.md +8 -0
  45. data/handbook/benchmarks/site-community/fixtures.json +41 -0
  46. data/handbook/benchmarks/site-course/brief.md +8 -0
  47. data/handbook/benchmarks/site-course/fixtures.json +41 -0
  48. data/handbook/benchmarks/site-studio/brief.md +8 -0
  49. data/handbook/benchmarks/site-studio/fixtures.json +38 -0
  50. data/handbook/catalog/app/error-recovery.md +11 -0
  51. data/handbook/catalog/app/status-feedback.md +11 -0
  52. data/handbook/catalog/app/task-first-density.md +11 -0
  53. data/handbook/catalog/shared/automated-a11y-boundary.md +11 -0
  54. data/handbook/catalog/shared/bounded-exploration.md +11 -0
  55. data/handbook/catalog/shared/context-before-style.md +11 -0
  56. data/handbook/catalog/shared/contextual-cards.md +11 -0
  57. data/handbook/catalog/shared/focus-visible.md +11 -0
  58. data/handbook/catalog/shared/responsive-reflow.md +11 -0
  59. data/handbook/catalog/site/critique-versus-defect.md +11 -0
  60. data/handbook/catalog/site/purposeful-links.md +11 -0
  61. data/handbook/catalog/site/subject-specific-hero.md +11 -0
  62. data/handbook/guides/design/process.g.md +20 -0
  63. data/handbook/skills/as-design-app/SKILL.md +21 -0
  64. data/handbook/skills/as-design-app-adjust/SKILL.md +23 -0
  65. data/handbook/skills/as-design-app-audit/SKILL.md +23 -0
  66. data/handbook/skills/as-design-app-implement/SKILL.md +22 -0
  67. data/handbook/skills/as-design-look-for-inspiration/SKILL.md +24 -0
  68. data/handbook/skills/as-design-site/SKILL.md +21 -0
  69. data/handbook/skills/as-design-site-adjust/SKILL.md +23 -0
  70. data/handbook/skills/as-design-site-audit/SKILL.md +23 -0
  71. data/handbook/skills/as-design-site-implement/SKILL.md +22 -0
  72. data/handbook/templates/design/benchmark-result.template.md +14 -0
  73. data/handbook/templates/design/brief.template.md +12 -0
  74. data/handbook/templates/design/feedback.template.md +8 -0
  75. data/handbook/templates/design/proposal.template.md +15 -0
  76. data/handbook/templates/design/source-ledger.template.md +5 -0
  77. data/handbook/workflow-instructions/design/adjust.wf.md +51 -0
  78. data/handbook/workflow-instructions/design/audit.wf.md +50 -0
  79. data/handbook/workflow-instructions/design/explore.wf.md +51 -0
  80. data/handbook/workflow-instructions/design/implement.wf.md +47 -0
  81. data/handbook/workflow-instructions/design/look-for-inspiration.wf.md +43 -0
  82. data/lib/ace/design/atoms/contracts.rb +36 -0
  83. data/lib/ace/design/cli/commands/adjust.rb +41 -0
  84. data/lib/ace/design/cli/commands/doctor.rb +18 -0
  85. data/lib/ace/design/cli/commands/explore.rb +42 -0
  86. data/lib/ace/design/cli/commands/inspect.rb +21 -0
  87. data/lib/ace/design/cli/commands/preset.rb +32 -0
  88. data/lib/ace/design/cli/commands/preview.rb +21 -0
  89. data/lib/ace/design/cli/commands/run_commands.rb +50 -0
  90. data/lib/ace/design/cli/commands/validate.rb +25 -0
  91. data/lib/ace/design/cli.rb +79 -0
  92. data/lib/ace/design/molecules/adjust_prompts.rb +40 -0
  93. data/lib/ace/design/molecules/agent_executor.rb +28 -0
  94. data/lib/ace/design/molecules/artifacts.rb +94 -0
  95. data/lib/ace/design/molecules/catalog.rb +36 -0
  96. data/lib/ace/design/molecules/context_document.rb +42 -0
  97. data/lib/ace/design/molecules/context_repository.rb +117 -0
  98. data/lib/ace/design/molecules/design_context.rb +96 -0
  99. data/lib/ace/design/molecules/design_prompts.rb +118 -0
  100. data/lib/ace/design/molecules/design_source.rb +89 -0
  101. data/lib/ace/design/molecules/preset.rb +120 -0
  102. data/lib/ace/design/molecules/runtime.rb +75 -0
  103. data/lib/ace/design/molecules/session.rb +123 -0
  104. data/lib/ace/design/molecules/skill_sources.rb +71 -0
  105. data/lib/ace/design/organisms/adjuster.rb +211 -0
  106. data/lib/ace/design/organisms/design_run.rb +394 -0
  107. data/lib/ace/design/organisms/generator.rb +127 -0
  108. data/lib/ace/design/organisms/inspector.rb +50 -0
  109. data/lib/ace/design/version.rb +6 -0
  110. data/lib/ace/design.rb +40 -0
  111. metadata +280 -0
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+ require "time"
3
+ module Ace
4
+ module Design
5
+ module Organisms
6
+ class Adjuster
7
+ A = Molecules::Artifacts
8
+ C = Molecules::DesignContext
9
+ D = Molecules::ContextDocument
10
+ def initialize(config: Design.config, executor: Molecules::AgentExecutor.new, contexts: C.new)
11
+ @config, @executor, @contexts = config, executor, contexts
12
+ end
13
+ def adjust(surface: nil, kind: nil, profile: nil, prompt: nil, refresh: false, dry_run: false, propose: false, model: nil)
14
+ raise Error, "refresh does not accept --prompt" if prompt && refresh
15
+ raise Error, "Use --dry-run or --propose, not both" if dry_run && propose
16
+ surface, kind = Molecules::DesignSource.scope(surface: surface, kind: kind, profile: profile)
17
+ Atoms::Contracts.text(prompt, "adjust prompt") if prompt
18
+ timeout = Integer(@config.fetch("agent_timeout"))
19
+ raise Error, "agent timeout must be 1..3600 seconds" unless (1..3600).cover?(timeout)
20
+ original = @contexts.repository.read(readonly: dry_run)
21
+ targets = @contexts.targets(original, surface: surface, kind: kind, context: profile)
22
+ # Detect hand edits before any model call, including edits to stale generated documents.
23
+ targets.each { |id| @contexts.read_generated(original, id) if original.key?(C.file(id, "design.md")) }
24
+ proposed = original.dup
25
+ targets.each do |id|
26
+ key = C.file(id, "defaults.md")
27
+ proposed[key] = @contexts.default(id) if refresh || !proposed.key?(key)
28
+ proposed[C.file(id, "user.md")] ||= ""
29
+ end
30
+ target = profile || surface
31
+ if prompt
32
+ key = C.file(target, "user.md")
33
+ proposed[key] += "\n\n## Decision update #{Time.now.utc.iso8601}\n\n#{prompt}\n"
34
+ end
35
+ if dry_run
36
+ return {"status" => "planned", "contexts" => targets, "refresh" => refresh,
37
+ "requested_model" => model || @config.fetch("model"),
38
+ "next" => "Run without --dry-run, or use --propose for an unpublished model composition"}
39
+ end
40
+ run = allocate_run
41
+ warn "Design adjustment: #{run}"
42
+ report = {"status" => "preparing", "report_dir" => run, "contexts" => targets,
43
+ "refresh" => refresh, "propose" => propose, "requested_model" => model || @config.fetch("model")}
44
+ dirty = []
45
+ targets.each do |id|
46
+ parent = id.empty? ? nil : C.chain(id)[-2]
47
+ if parent && dirty.include?(parent)
48
+ dirty << id
49
+ next
50
+ end
51
+ begin
52
+ @contexts.resolve(proposed, id)
53
+ rescue Error
54
+ dirty << id
55
+ end
56
+ end
57
+ report["changed_contexts"] = dirty
58
+ inputs = dirty.map do |id|
59
+ parent_id = id.empty? ? nil : C.chain(id)[-2]
60
+ {"id" => id, "parent" => parent_id, "old_defaults" => original[C.file(id, "defaults.md")],
61
+ "parent_design" => parent_id && !dirty.include?(parent_id) ? proposed.fetch(C.file(parent_id, "design.md")) : nil,
62
+ "new_defaults" => proposed.fetch(C.file(id, "defaults.md")),
63
+ "user" => proposed.fetch(C.file(id, "user.md")), "previous_design" => original[C.file(id, "design.md")]}
64
+ end
65
+ A.json(File.join(run, "request.json"), {"contexts" => inputs})
66
+ if dirty.empty?
67
+ report["status"] = "unchanged"
68
+ return save_report(run, report)
69
+ end
70
+ workspace = File.join(run, "worker")
71
+ FileUtils.mkdir_p(workspace)
72
+ FileUtils.cp(File.join(run, "request.json"), workspace)
73
+ File.write(File.join(workspace, "system.md"), Molecules::AdjustPrompts::SYSTEM)
74
+ File.write(File.join(workspace, "prompt.md"), Molecules::AdjustPrompts::PROMPT)
75
+ @executor.validate_model(report.fetch("requested_model"))
76
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
+ result = @executor.call(model: report.fetch("requested_model"), prompt: Molecules::AdjustPrompts::PROMPT,
78
+ system: Molecules::AdjustPrompts::SYSTEM, workspace: workspace, timeout: timeout)
79
+ report.merge!(result.slice("execution", "model", "provider", "usage", "metadata"))
80
+ A.json(File.join(workspace, "execution.json"), result)
81
+ raise Error, "Adjustment agent did not complete successfully" unless result.dig("execution", "status") == "succeeded"
82
+ unless !File.symlink?(File.join(workspace, "request.json")) && File.binread(File.join(workspace, "request.json")) == File.binread(File.join(run, "request.json"))
83
+ raise Error, "Adjustment agent modified its inputs"
84
+ end
85
+ output_path = File.join(workspace, "result.json")
86
+ raise Error, "Adjustment result missing or symlink" unless File.file?(output_path) && !File.symlink?(output_path)
87
+ output = validate_output(A.object(output_path), dirty)
88
+ report["conflicts"] = output.fetch("conflicts")
89
+ report["summary"] = output.fetch("contexts").transform_values { |node| node.reject { |key, _| key == "design" } }
90
+ if report["conflicts"].empty?
91
+ dirty.each do |id|
92
+ parent = id.empty? ? nil : proposed.fetch(C.file(C.chain(id)[-2], "design.md"))
93
+ decisions = C.chain(id).to_h { |ancestor| [ancestor, proposed.fetch(C.file(ancestor, "user.md"))] }
94
+ proposed[C.file(id, "design.md")] = D.effective(id: id, defaults: proposed.fetch(C.file(id, "defaults.md")),
95
+ user: proposed.fetch(C.file(id, "user.md")), parent: parent,
96
+ body: output.fetch("contexts").fetch(id).fetch("design"), decisions: decisions)
97
+ end
98
+ targets.each { |id| @contexts.resolve(proposed, id) }
99
+ candidate = write_candidate(run, proposed)
100
+ write_diff(run, original, proposed)
101
+ report["stale_contexts"] = @contexts.ids(proposed).reject do |id|
102
+ @contexts.resolve(proposed, id)
103
+ true
104
+ rescue Error
105
+ false
106
+ end
107
+ if propose
108
+ report["status"] = "proposed"
109
+ else
110
+ report["status"] = "proposed"
111
+ save_report(run, report)
112
+ @contexts.repository.publish(candidate: candidate, original: original, run: run)
113
+ report["status"] = "published"
114
+ begin
115
+ return save_report(run, report)
116
+ rescue StandardError => error
117
+ warn "Contexts published, but report update failed: #{error.message}"
118
+ return report
119
+ end
120
+ end
121
+ else
122
+ report["status"] = "conflict"
123
+ end
124
+ save_report(run, report)
125
+ rescue StandardError, Interrupt => error
126
+ if report && run
127
+ report["status"] = error.is_a?(Interrupt) ? "interrupted" : "failed"
128
+ report["error"] = error.message
129
+ begin
130
+ save_report(run, report)
131
+ rescue StandardError
132
+ # Keep the original failure authoritative when diagnostics cannot be saved.
133
+ nil
134
+ end
135
+ end
136
+ raise
137
+ ensure
138
+ if report && started
139
+ report["duration"] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
140
+ begin
141
+ A.json(File.join(run, "report.json"), report)
142
+ rescue StandardError => error
143
+ warn "Could not persist adjustment timing: #{error.message}"
144
+ end
145
+ end
146
+ end
147
+ private
148
+ def allocate_run
149
+ base = @contexts.repository.runs
150
+ @contexts.repository.safe_path!(base)
151
+ FileUtils.mkdir_p(base)
152
+ 100.times do
153
+ directory = File.join(base, Ace::B36ts.encode(Time.now, format: :ms))
154
+ begin
155
+ Dir.mkdir(directory)
156
+ return directory
157
+ rescue Errno::EEXIST
158
+ sleep 0.002
159
+ end
160
+ end
161
+ raise Error, "Could not allocate adjustment report"
162
+ end
163
+ def validate_output(output, targets)
164
+ nodes = output["contexts"]
165
+ conflicts = output["conflicts"]
166
+ unless output.keys.sort == %w[conflicts contexts] && nodes.is_a?(Hash) && nodes.keys.sort == targets.sort &&
167
+ conflicts.is_a?(Array) && conflicts.all? { |item| item.is_a?(String) && !item.strip.empty? }
168
+ raise Error, "Adjustment result must contain every context and a conflicts array"
169
+ end
170
+ nodes.each do |id, node|
171
+ unless node.is_a?(Hash) && node.keys.sort == %w[added_rules design omitted_defaults preserved_exceptions]
172
+ raise Error, "Invalid adjustment result for #{id}"
173
+ end
174
+ Atoms::Contracts.text(node["design"], "design for #{id}")
175
+ %w[added_rules preserved_exceptions omitted_defaults].each do |field|
176
+ unless node[field].is_a?(Array) && node[field].all? { |item| item.is_a?(String) }
177
+ raise Error, "Adjustment #{field} must be a text array"
178
+ end
179
+ end
180
+ end
181
+ output
182
+ end
183
+ def write_candidate(run, files)
184
+ candidate = File.join(run, "candidate")
185
+ files.each do |name, content|
186
+ target = File.join(candidate, name)
187
+ FileUtils.mkdir_p(File.dirname(target))
188
+ File.binwrite(target, content)
189
+ end
190
+ candidate
191
+ end
192
+ def write_diff(run, before, after)
193
+ text = (before.keys | after.keys).sort.filter_map do |name|
194
+ next if before[name] == after[name]
195
+ old = before.fetch(name, "").lines
196
+ new = after.fetch(name, "").lines
197
+ "--- #{name}\n+++ #{name}\n@@ -1,#{old.size} +1,#{new.size} @@\n" +
198
+ old.map { |line| "-#{line.chomp}\n" }.join + new.map { |line| "+#{line.chomp}\n" }.join
199
+ end.join("\n")
200
+ File.write(File.join(run, "changes.diff"), text)
201
+ end
202
+ def save_report(run, report)
203
+ A.json(File.join(run, "report.json"), report)
204
+ File.write(File.join(run, "report.md"), "# Design adjustment: #{report.fetch('status')}\n\n" +
205
+ "```json\n#{JSON.pretty_generate(report)}\n```\n\nSee changes.diff and worker/result.json when present.\n")
206
+ report
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+ require "fileutils"
3
+ require "securerandom"
4
+ require "digest"
5
+ require "time"
6
+ module Ace
7
+ module Design
8
+ module Organisms
9
+ class DesignRun
10
+ A = Molecules::Artifacts
11
+ def initialize(root, executor: Molecules::AgentExecutor.new)
12
+ @root = File.realpath(root)
13
+ @executor = executor
14
+ @mutex = Mutex.new
15
+ reload
16
+ end
17
+ def status
18
+ state = A.object(path("run.json"))
19
+ state.merge("id" => File.basename(@root), "next" => "ace-design preview #{@root}", "session" => @root, "gallery" => File.file?(path("gallery.html")) ? path("gallery.html") : nil)
20
+ end
21
+ def resume
22
+ locked do
23
+ verify_inputs!
24
+ verify_published!
25
+ unless @state.fetch("preparation")["status"] == "succeeded"
26
+ prepare
27
+ end
28
+ if @state.fetch("preparation")["status"] == "succeeded"
29
+ pending = @preset.fetch("variants").reject { |s| @state.fetch("workers").fetch(s.fetch("id"))["status"] == "succeeded" }
30
+ Molecules::Session.new(@root).write_gallery
31
+ execute_slots(pending)
32
+ end
33
+ finish
34
+ end
35
+ end
36
+ def revise(variant:, prompt:)
37
+ Atoms::Contracts.text(prompt, "feedback")
38
+ locked do
39
+ verify_inputs!
40
+ verify_published!
41
+ slot = @preset.fetch("variants").find { |s| s.fetch("id") == variant }
42
+ raise Error, "Unknown variant: #{variant}" unless slot
43
+ raise Error, "Variant #{variant} has no published mockup" unless File.directory?(path("variants/#{variant}"))
44
+ @state["status"] = "running"
45
+ save
46
+ execute_slot(slot, feedback: prompt)
47
+ finish
48
+ end
49
+ end
50
+ def select(variant:, decisions:)
51
+ locked do
52
+ verify_inputs!
53
+ verify_published!
54
+ session = Molecules::Session.new(@root)
55
+ raise Error, "Select an available variant" unless session.variants.include?(variant)
56
+ unless decisions.is_a?(Array) && !decisions.empty? && decisions.all? { |d| d.is_a?(String) && !d.strip.empty? }
57
+ raise Error, "Provide at least one nonempty --decision"
58
+ end
59
+ selection = {"surface" => @metadata.fetch("surface"), "variant" => variant,
60
+ "fixtures_sha256" => Digest::SHA256.file(path("fixtures.json")).hexdigest,
61
+ "variant_sha256" => A.digest(path("variants/#{variant}")), "decisions" => decisions}
62
+ A.json(path("selection.json"), selection)
63
+ session.validate_selection
64
+ selection
65
+ end
66
+ end
67
+ private
68
+ def path(relative)
69
+ File.join(@root, relative)
70
+ end
71
+ def reload
72
+ @metadata = A.object(path("session.json"))
73
+ @preset = A.object(path("resolved.json"))
74
+ @state = A.object(path("run.json"))
75
+ unless %w[view component].include?(@metadata["kind"]) && @state.dig("inputs", "design.md")
76
+ raise Error, "Session lacks task kind or frozen design assumptions; create a new session"
77
+ end
78
+ end
79
+ def locked
80
+ raise Error, "Refusing symlink lock" if File.symlink?(path(".run.lock"))
81
+ File.open(path(".run.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
82
+ raise Error, "Session already has an active operation" unless lock.flock(File::LOCK_EX | File::LOCK_NB)
83
+ reload
84
+ Thread.handle_interrupt(Interrupt => :never) do
85
+ recover_publication
86
+ yield
87
+ end
88
+ rescue Interrupt => error
89
+ @state["status"] = "interrupted"
90
+ save_preserving_failure(error)
91
+ raise
92
+ ensure
93
+ lock.flock(File::LOCK_UN)
94
+ end
95
+ end
96
+ def save
97
+ A.json(path("run.json"), @state)
98
+ end
99
+ # A diagnostic write must not replace the error that triggered it.
100
+ def save_preserving_failure(failure)
101
+ save
102
+ rescue StandardError => persistence_error
103
+ raise unless failure
104
+ warn "Could not persist design state: #{persistence_error.message}"
105
+ raise failure
106
+ end
107
+ def verify_inputs!
108
+ @state.fetch("inputs").each do |file, digest|
109
+ target = path(file)
110
+ unless File.file?(target) && !File.symlink?(target) && Digest::SHA256.file(target).hexdigest == digest
111
+ raise Error, "Shared input changed: #{file}; create a new session"
112
+ end
113
+ end
114
+ end
115
+ def verify_published!
116
+ @state.fetch("workers").each do |id, worker|
117
+ next unless worker["published_sha256"]
118
+ unless A.digest(path("variants/#{id}")) == worker["published_sha256"]
119
+ raise Error, "Published variant #{id} changed outside the runner; create a new session"
120
+ end
121
+ end
122
+ end
123
+ def workspace(id)
124
+ directory = path("work/#{id}/#{SecureRandom.hex(8)}")
125
+ FileUtils.mkdir_p(directory)
126
+ %w[brief.md context.md design.md fixtures.json].each do |name|
127
+ FileUtils.cp(path(name), File.join(directory, name)) if File.file?(path(name))
128
+ end
129
+ %w[source skills].each do |name|
130
+ FileUtils.cp_r(path(name), File.join(directory, name)) if File.directory?(path(name))
131
+ end
132
+ FileUtils.cp(path("source-analysis.md"), directory) if File.file?(path("source-analysis.md"))
133
+ directory
134
+ end
135
+ def invoke(directory, prompt, model:, execution_preset: nil, system: Molecules::DesignPrompts::SYSTEM)
136
+ same_project = begin
137
+ File.realpath(Dir.pwd) == File.realpath(@state.fetch("project_root"))
138
+ rescue SystemCallError
139
+ false
140
+ end
141
+ unless same_project
142
+ raise Error, "Run model operations from #{@state.fetch('project_root')} to retain the project's ace-llm policies"
143
+ end
144
+ File.write(File.join(directory, "system.md"), system)
145
+ File.write(File.join(directory, "prompt.md"), prompt)
146
+ @executor.validate_model(model, execution_preset: execution_preset)
147
+ result = Thread.handle_interrupt(Interrupt => :immediate) do
148
+ @executor.call(model: model, prompt: prompt, system: system,
149
+ workspace: directory, timeout: @preset.fetch("timeout"), execution_preset: execution_preset)
150
+ end
151
+ A.json(File.join(directory, "result.json"), result)
152
+ unless result.dig("execution", "status") == "succeeded"
153
+ raise Error, "Coding agent did not complete successfully; see #{directory}/result.json"
154
+ end
155
+ result
156
+ end
157
+ def check_copies(directory, names)
158
+ names += @state.fetch("inputs").keys.select { |name| name.start_with?("source/", "skills/") || name == "source-analysis.md" }
159
+ names.each do |name|
160
+ target = File.join(directory, name)
161
+ unless File.file?(target) && !File.symlink?(target) && File.binread(target) == File.binread(path(name))
162
+ raise Error, "Worker modified shared input #{name}"
163
+ end
164
+ end
165
+ end
166
+ def prepare
167
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
168
+ directory = workspace("preparation")
169
+ attempt = {"status" => "running", "workspace" => directory, "requested_model" => @preset.fetch("preparation_model")}
170
+ @state["preparation_attempts"] ||= []
171
+ @state["preparation_attempts"] << attempt
172
+ @state["preparation"] = attempt
173
+ @state["status"] = "running"
174
+ save
175
+ prompt = Molecules::DesignPrompts.preparation(@metadata, @preset, supplied: @state["supplied_fixtures"])
176
+ A.write(path("prompts/preparation.md"), prompt)
177
+ result = invoke(directory, prompt, model: @preset.fetch("preparation_model"))
178
+ attempt.merge!(evidence(result))
179
+ check_copies(directory, %w[brief.md context.md design.md] + (@state["supplied_fixtures"] ? ["fixtures.json"] : []))
180
+ fixture = File.join(directory, "fixtures.json")
181
+ directions_path = File.join(directory, "directions.json")
182
+ [fixture, directions_path].each { |f| raise Error, "Preparation output missing or symlink: #{f}" unless File.file?(f) && !File.symlink?(f) }
183
+ A.object(fixture)
184
+ directions = A.object(directions_path)
185
+ ids = @metadata.fetch("variants")
186
+ unless directions.keys.sort == ids.sort && directions.values.all? { |v| v.is_a?(String) && !v.strip.empty? }
187
+ raise Error, "directions.json must map every declared variant ID to a nonempty brief"
188
+ end
189
+ if @metadata["source"]
190
+ analysis = File.join(directory, "source-analysis.md")
191
+ raise Error, "Preparation must write source-analysis.md" unless File.file?(analysis) && !File.symlink?(analysis) && !File.read(analysis).strip.empty?
192
+ A.write(path("source-analysis.md"), File.binread(analysis))
193
+ @state["inputs"]["source-analysis.md"] = Digest::SHA256.file(analysis).hexdigest
194
+ end
195
+ verify_inputs!
196
+ A.write(path("fixtures.json"), File.binread(fixture)) unless @state["supplied_fixtures"]
197
+ A.json(path("directions.json"), directions)
198
+ %w[fixtures.json directions.json].each { |f| @state["inputs"][f] = Digest::SHA256.file(path(f)).hexdigest }
199
+ attempt["status"] = "succeeded"
200
+ @preset.fetch("variants").each do |slot|
201
+ File.write(path("prompts/#{slot.fetch('id')}.md"), Molecules::DesignPrompts.variant(@metadata, slot, direction: directions.fetch(slot.fetch("id"))))
202
+ end
203
+ rescue StandardError => e
204
+ failure = e
205
+ raise unless attempt
206
+ attempt.merge!("status" => "failed", "error" => e.message)
207
+ rescue Interrupt => error
208
+ failure = error
209
+ attempt["status"] = "interrupted" if attempt
210
+ raise
211
+ ensure
212
+ if attempt && started
213
+ attempt["duration"] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
214
+ save_preserving_failure(failure)
215
+ end
216
+ end
217
+ def execute_slots(slots)
218
+ failure = nil
219
+ threads = []
220
+ begin
221
+ @state["status"] = "running"
222
+ save
223
+ queue = Queue.new
224
+ slots.each { |slot| queue << slot }
225
+ [@preset.fetch("concurrency"), slots.size].min.times do
226
+ threads << Thread.new do
227
+ Thread.current.report_on_exception = false
228
+ loop do
229
+ slot = begin
230
+ queue.pop(true)
231
+ rescue ThreadError
232
+ break
233
+ end
234
+ execute_slot(slot)
235
+ end
236
+ end
237
+ end
238
+ Thread.handle_interrupt(Interrupt => :immediate) { threads.each(&:value) }
239
+ rescue Interrupt, StandardError => error
240
+ failure = error
241
+ ensure
242
+ # Interrupt the provider capture boundary, which owns descendant cleanup.
243
+ threads.each { |t| t.raise(Interrupt) if t.alive? }
244
+ threads.each do |thread|
245
+ begin
246
+ thread.join
247
+ rescue Interrupt, StandardError => error
248
+ # Join every sibling even if recording a failure also failed.
249
+ failure ||= error
250
+ end
251
+ end
252
+ end
253
+ raise failure if failure
254
+ end
255
+ def execute_slot(slot, feedback: nil)
256
+ # Cancellation is delivered at the provider boundary. File publication
257
+ # and its journal must finish atomically with respect to Ruby interrupts.
258
+ Thread.handle_interrupt(Interrupt => :never) { execute_slot_work(slot, feedback: feedback) }
259
+ end
260
+ def execute_slot_work(slot, feedback: nil)
261
+ id = slot.fetch("id")
262
+ worker = @state.fetch("workers").fetch(id)
263
+ # A failed/interrupted revision is resumed with the same feedback, not a fresh generation.
264
+ feedback ||= worker["pending_feedback"]
265
+ directory = workspace(id)
266
+ output = File.join(directory, "variants", id)
267
+ FileUtils.mkdir_p(File.dirname(output))
268
+ FileUtils.cp_r(path("variants/#{id}"), output) if feedback
269
+ FileUtils.mkdir_p(output)
270
+ directions = A.object(path("directions.json"))
271
+ prompt = Molecules::DesignPrompts.variant(@metadata, slot, direction: directions.fetch(id), feedback: feedback)
272
+ attempt = {"status" => "running", "workspace" => directory, "requested_model" => slot.fetch("model"),
273
+ "feedback" => feedback, "started_at" => Time.now.utc.iso8601}
274
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
275
+ @mutex.synchronize do
276
+ worker["status"] = "running"
277
+ worker["pending_feedback"] = feedback
278
+ worker.fetch("attempts") << attempt
279
+ save
280
+ end
281
+ warn "#{id}: generating with #{slot.fetch('model')}"
282
+ result = invoke(directory, prompt, model: slot.fetch("model"), execution_preset: slot["execution_preset"],
283
+ system: slot.fetch("process", "ace") == "ace" ? Molecules::DesignPrompts::SYSTEM : Molecules::DesignPrompts::EXTERNAL_SYSTEM)
284
+ @mutex.synchronize { attempt.merge!(evidence(result)); save }
285
+ check_copies(directory, %w[brief.md context.md design.md fixtures.json])
286
+ digest = A.validate_variant(output, types: slot.fetch("artifact_types", ["html"]), shared_reference: slot.fetch("process", "ace") == "ace")
287
+ @mutex.synchronize do
288
+ verify_inputs!
289
+ publish(id, output, digest)
290
+ worker.merge!("status" => "succeeded", "published_sha256" => digest)
291
+ worker.delete("pending_feedback")
292
+ attempt.merge!(evidence(result)).merge!("status" => "succeeded")
293
+ if feedback
294
+ File.open(path("feedback.md"), "a") do |file|
295
+ file.puts "\n## #{Time.now.utc.iso8601} — #{id}\n\n#{feedback}\n\nPublished: #{digest}\nAttempt: #{directory}\n"
296
+ end
297
+ end
298
+ save
299
+ @state.delete("publication")
300
+ save
301
+ Molecules::Session.new(@root).write_gallery
302
+ end
303
+ warn "#{id}: ready"
304
+ rescue StandardError => e
305
+ failure = e
306
+ warn "#{id}: failed — #{e.message}"
307
+ @mutex.synchronize do
308
+ recover_publication
309
+ worker["status"] = "failed"
310
+ attempt&.merge!("status" => "failed", "error" => e.message)
311
+ save_preserving_failure(e)
312
+ end
313
+ rescue Interrupt => error
314
+ failure = error
315
+ @mutex.synchronize do
316
+ worker["status"] = "interrupted"
317
+ attempt&.merge!("status" => "interrupted")
318
+ save_preserving_failure(error)
319
+ end
320
+ raise
321
+ ensure
322
+ if attempt
323
+ @mutex.synchronize do
324
+ attempt["duration"] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
325
+ save_preserving_failure(failure)
326
+ end
327
+ end
328
+ end
329
+ def publish(id, output, digest)
330
+ target = path("variants/#{id}")
331
+ backup = path("history/#{id}/#{SecureRandom.hex(8)}")
332
+ FileUtils.mkdir_p(File.dirname(target))
333
+ FileUtils.mkdir_p(File.dirname(backup))
334
+ previous = @state.fetch("workers").fetch(id)["published_sha256"]
335
+ selection = path("selection.json")
336
+ selection_backup = if File.exist?(selection) && A.object(selection)["variant"] == id
337
+ path("history/selections/#{SecureRandom.hex(8)}.json")
338
+ end
339
+ @state["publication"] = {"id" => id, "backup" => backup, "previous" => previous,
340
+ "digest" => digest, "selection_backup" => selection_backup}
341
+ save
342
+ File.rename(target, backup) if File.exist?(target)
343
+ File.rename(output, target)
344
+ if selection_backup
345
+ FileUtils.mkdir_p(File.dirname(selection_backup))
346
+ File.rename(selection, selection_backup)
347
+ end
348
+ end
349
+ def recover_publication
350
+ publication = @state["publication"]
351
+ return unless publication
352
+ id = publication.fetch("id")
353
+ target = path("variants/#{id}")
354
+ worker = @state.fetch("workers").fetch(id)
355
+ # A saved success is committed even if the process stopped before clearing the journal.
356
+ if worker["status"] == "succeeded" && worker["published_sha256"] == publication["digest"] &&
357
+ File.directory?(target) && A.digest(target) == publication["digest"]
358
+ @state.delete("publication")
359
+ save
360
+ return
361
+ end
362
+ if File.directory?(publication.fetch("backup"))
363
+ FileUtils.rm_rf(target)
364
+ File.rename(publication.fetch("backup"), target)
365
+ elsif !publication["previous"]
366
+ FileUtils.rm_rf(target)
367
+ end
368
+ if publication["selection_backup"] && File.file?(publication["selection_backup"])
369
+ File.rename(publication["selection_backup"], path("selection.json"))
370
+ end
371
+ worker["status"] = "interrupted"
372
+ worker["published_sha256"] = publication["previous"]
373
+ @state.delete("publication")
374
+ save
375
+ end
376
+ def evidence(result)
377
+ result.slice("execution", "model", "provider", "usage", "metadata")
378
+ end
379
+ def finish
380
+ @state["status"] = if @state["preparation"]["status"] != "succeeded"
381
+ "failed"
382
+ elsif @state.fetch("workers").values.all? { |w| w["status"] == "succeeded" }
383
+ "succeeded"
384
+ else
385
+ "incomplete"
386
+ end
387
+ save
388
+ Molecules::Session.new(@root).write_gallery if File.file?(path("directions.json"))
389
+ status
390
+ end
391
+ end
392
+ end
393
+ end
394
+ end