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,89 @@
1
+ # frozen_string_literal: true
2
+ require "uri"
3
+ module Ace
4
+ module Design
5
+ module Molecules
6
+ # Resolves provenance without fetching during dry-run; captures once before preparation.
7
+ class DesignSource
8
+ attr_reader :info
9
+ def initialize(reference, config: Design.config)
10
+ @config = config
11
+ @info = if reference.nil?
12
+ nil
13
+ elsif reference.start_with?("session:")
14
+ match = /\Asession:([0-9a-z]+)\/([a-z][a-z0-9_-]{0,31})\z/.match(reference)
15
+ raise Error, "Expected session:TOKEN/VARIANT" unless match
16
+ root = File.expand_path(File.join(config.fetch("run_root"), match[1]))
17
+ session = Session.new(root)
18
+ raise Error, "Source variant is not available" unless session.variants.include?(match[2])
19
+ %w[fixtures.json design.md brief.md].each do |name|
20
+ unless File.file?(File.join(root, name)) && File.readable?(File.join(root, name))
21
+ raise Error, "Source session #{match[1]} is missing readable #{name}; create a new session with complete shared inputs"
22
+ end
23
+ end
24
+ Artifacts.files(File.join(root, "variants", match[2]))
25
+ {"type" => "session", "reference" => reference, "root" => root, "variant" => match[2],
26
+ "surface" => session.metadata.fetch("surface"), "kind" => session.metadata.fetch("kind")}
27
+ elsif reference.match?(/\Ahttps?:\/\//)
28
+ uri = URI.parse(reference)
29
+ raise Error, "Source URL requires a host and no credentials" if !uri.host || uri.userinfo
30
+ {"type" => "url", "reference" => reference}
31
+ else
32
+ file = File.realpath(reference)
33
+ raise Error, "Source must be a PNG, JPEG, WebP or GIF file" unless File.file?(file) && file.match?(/\.(png|jpe?g|webp|gif)\z/i)
34
+ {"type" => "image", "reference" => file}
35
+ end
36
+ rescue SystemCallError, URI::InvalidURIError => e
37
+ raise Error, "Invalid design source: #{e.message}"
38
+ end
39
+ def self.scope(surface:, kind:, profile: nil, source: nil)
40
+ expected = profile ? profile.split("/").first(2) : [source&.dig("surface"), source&.dig("kind")]
41
+ expected[1] = nil unless %w[view component].include?(expected[1])
42
+ [[surface, expected[0], "surface"], [kind, expected[1], "kind"]].each do |explicit, inherited, field|
43
+ raise Error, "Conflicting #{field}: #{explicit} versus #{inherited}" if explicit && inherited && explicit != inherited
44
+ end
45
+ result = [surface || expected[0], kind || expected[1] || "view"]
46
+ Atoms::Contracts.surface(result[0])
47
+ Atoms::Contracts.kind(result[1])
48
+ if source && source["surface"] && result != source.values_at("surface", "kind")
49
+ raise Error, "Source session scope conflicts with the selected profile/task"
50
+ end
51
+ result
52
+ end
53
+ def capture_url(url, target)
54
+ runtime = Runtime.new(@config)
55
+ result = runtime.capture(runtime.node, runtime.script("source.mjs"), runtime.directory, url, target, timeout: 90)
56
+ raise Error, "Source capture failed: #{result['stderr']}" unless result["exit_code"] == 0
57
+ end
58
+ def requirements
59
+ remote = info && (info["type"] == "url" || (info["type"] == "session" &&
60
+ Artifacts.descriptor(File.join(info.fetch("root"), "variants", info.fetch("variant")))["type"] == "url"))
61
+ return [] unless remote
62
+ [{"name" => "Source capture: configured Node, Playwright and Chromium", "status" => "agent_check",
63
+ "check" => "ace-design doctor", "node" => @config.fetch("node"), "runtime_dir" => @config.fetch("runtime_dir")}]
64
+ end
65
+ def snapshot(root)
66
+ return unless info
67
+ target = File.join(root, "source")
68
+ FileUtils.mkdir_p(target)
69
+ case info.fetch("type")
70
+ when "image"
71
+ FileUtils.cp(info.fetch("reference"), File.join(target, "mockup#{File.extname(info.fetch('reference')).downcase}"))
72
+ when "url"
73
+ capture_url(info.fetch("reference"), target)
74
+ when "session"
75
+ source = File.join(info.fetch("root"), "variants", info.fetch("variant"))
76
+ Artifacts.files(source)
77
+ FileUtils.cp_r(source, File.join(target, "variant"))
78
+ artifact = Artifacts.descriptor(source)
79
+ capture_url(artifact.fetch("entry"), target) if artifact["type"] == "url"
80
+ %w[fixtures.json design.md brief.md].each do |name|
81
+ FileUtils.cp(File.join(info.fetch("root"), name), File.join(target, name))
82
+ end
83
+ end
84
+ Artifacts.json(File.join(target, "source.json"), info)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+ module Ace
3
+ module Design
4
+ module Molecules
5
+ class Preset
6
+ ID = /\A[a-z][a-z0-9_-]{0,31}\z/
7
+ def self.integer(value, label)
8
+ unless value.is_a?(Integer) || (value.is_a?(String) && /\A[+-]?\d+\z/.match?(value))
9
+ raise Error, "#{label} must be an integer"
10
+ end
11
+ Integer(value)
12
+ end
13
+ def self.definition(name)
14
+ if File.file?(name)
15
+ data = YAML.safe_load_file(name)
16
+ raise Error, "Preset file must be an object" unless data.is_a?(Hash)
17
+ data
18
+ else
19
+ raise Error, "Invalid preset name" unless ID.match?(name)
20
+ data = Ace::Support::Config.create(gem_path: Design.gem_root)
21
+ .resolve_namespace("design", filename: "presets/#{name}").to_h
22
+ raise Error, "Unknown preset: #{name}" if data.empty?
23
+ data
24
+ end
25
+ end
26
+ def self.compatibility!(data, intent, surface, kind)
27
+ {"intents" => intent, "surfaces" => surface, "kinds" => kind}.each do |key, value|
28
+ next unless data[key]
29
+ allowed = {"intents" => %w[explore recreate reimagine], "surfaces" => %w[site app], "kinds" => %w[view component]}.fetch(key)
30
+ unless data[key].is_a?(Array) && !data[key].empty? && (data[key] - allowed).empty?
31
+ raise Error, "Preset #{key} must be a nonempty list drawn from #{allowed.join(', ')}"
32
+ end
33
+ raise Error, "Preset does not support #{key}: #{value}" unless data.fetch(key).include?(value)
34
+ end
35
+ end
36
+ def self.list
37
+ paths = Ace::Support::Config::Molecules::ConfigFinder.new(gem_path: Design.gem_root,
38
+ file_patterns: ["design/presets/*.yml", "design/presets/*.yaml"]).find_all
39
+ paths.map { |entry| File.basename(entry.path).sub(/\.ya?ml\z/, "") }
40
+ .uniq.sort.map { |name| definition(name).merge("name" => name) }
41
+ end
42
+ def self.load(name:, surface:, config: Design.config, model: nil, prompts: [], prompt_files: [], kind: "view", intent: "explore")
43
+ raise Error, "Invalid preset name" unless name.is_a?(String) && (ID.match?(name) || File.file?(name))
44
+ Atoms::Contracts.surface(surface)
45
+ Atoms::Contracts.kind(kind)
46
+ data = definition(name)
47
+ raise Error, "Unknown or empty design preset: #{name}" if data.empty?
48
+ raise Error, "Unsupported intent: #{intent}" unless %w[explore recreate reimagine].include?(intent)
49
+ compatibility!(data, intent, surface, kind)
50
+ slots = data["variants"] || Array.new(intent == "recreate" ? 1 : 3) { |i| {"id" => (97 + i).chr} }
51
+ unless slots.is_a?(Array) && !slots.empty? && slots.all? { |slot| slot.is_a?(Hash) }
52
+ raise Error, "Preset variants must be a nonempty list of objects"
53
+ end
54
+ slots = slots.map do |slot|
55
+ base = slot["preset"] ? definition(slot["preset"]) : data
56
+ raise Error, "A variant must reference a single-process preset, not a comparison" if slot["preset"] && base.key?("variants")
57
+ compatibility!(base, intent, surface, kind)
58
+ JSON.parse(JSON.generate(base.reject { |key, _| %w[variants concurrency].include?(key) }.merge(slot)))
59
+ end
60
+ unless slots.is_a?(Array) && !slots.empty? && slots.all? { |s| s.is_a?(Hash) && ID.match?(s["id"].to_s) }
61
+ raise Error, "Preset variants must be a nonempty list with safe lowercase IDs"
62
+ end
63
+ ids = slots.map { |s| s["id"] }
64
+ raise Error, "Variant IDs must be unique" unless ids.uniq == ids
65
+ extras = ids.to_h { |id| [id, []] }
66
+ [[prompts, false], [prompt_files, true]].each do |entries, file|
67
+ entries.each do |entry|
68
+ id, value = entry.split("=", 2)
69
+ raise Error, "Expected declared variant ID=VALUE: #{entry}" unless extras.key?(id) && value && !value.empty?
70
+ begin
71
+ extras[id] << (file ? File.read(value) : value)
72
+ rescue SystemCallError => e
73
+ raise Error, "Variant prompt file #{id}=#{value}: #{e.message}"
74
+ end
75
+ end
76
+ end
77
+ slots = slots.map do |slot|
78
+ focus = slot["focus"] || Array(data["directions"])[ids.index(slot["id"])]
79
+ focus = (kind == "component" && focus["component"]) || focus[surface] if focus.is_a?(Hash)
80
+ resolved = slot.merge("focus" => focus.to_s,
81
+ "model" => model || slot["model"] || data["model"] || config.fetch("model"),
82
+ "role" => slot.fetch("role", "Design a distinct, useful #{surface} mockup."),
83
+ "prompt" => ([slot["prompt"]] + extras.fetch(slot["id"])).compact.join("\n\n"))
84
+ %w[model role prompt focus].each do |key|
85
+ raise Error, "Variant #{slot['id']} #{key} must be text" unless resolved[key].is_a?(String)
86
+ end
87
+ Atoms::Contracts.text(resolved["model"], "variant model")
88
+ if resolved.key?("execution_preset")
89
+ Atoms::Contracts.text(resolved["execution_preset"], "execution preset")
90
+ raise Error, "Use model@preset or execution_preset, not both" if resolved["model"].include?("@")
91
+ end
92
+ resolved.delete("directions")
93
+ process = resolved.fetch("process", "ace")
94
+ raise Error, "Unknown preset process: #{process}" unless %w[ace external].include?(process)
95
+ if process == "external"
96
+ skills = resolved["skills"]
97
+ unless skills.is_a?(Array) && !skills.empty? && skills.all? { |skill| skill.is_a?(Hash) && skill["source"].is_a?(String) && skill["entry"].is_a?(String) && /\A[0-9a-f]{40}\z/.match?(skill["revision"].to_s) }
98
+ raise Error, "External preset requires pinned skill sources, entries and full revisions"
99
+ end
100
+ raise Error, "External preset needs exactly one lead skill" unless skills.count { |skill| skill.fetch("role", "lead") == "lead" } == 1
101
+ end
102
+ types = resolved.fetch("artifact_types", ["html"])
103
+ raise Error, "Invalid artifact_types" unless types.is_a?(Array) && !types.empty? && (types - %w[html image url]).empty?
104
+ resolved
105
+ end
106
+ concurrency = integer(data.fetch("concurrency", 3), "concurrency")
107
+ timeout = integer(data.fetch("timeout", config.fetch("agent_timeout")), "agent timeout")
108
+ raise Error, "concurrency must be positive" unless concurrency.positive?
109
+ raise Error, "agent timeout must be 1..3600 seconds" unless (1..3600).cover?(timeout)
110
+ {"name" => name, "variants" => slots, "concurrency" => [concurrency, slots.size].min,
111
+ "timeout" => timeout, "preparation_model" => model || data["preparation_model"] || data["model"] || config.fetch("model"),
112
+ "input" => Array(config["input"]) + Array(data["input"]), "intent" => intent,
113
+ "model_override" => model, "description" => data["description"]}
114
+ rescue SystemCallError, ArgumentError, TypeError => e
115
+ raise Error, "Invalid design preset: #{e.message}"
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+ require "open3"
3
+ require "ace/core/atoms/process_terminator"
4
+ module Ace
5
+ module Design
6
+ module Molecules
7
+ class Runtime
8
+ attr_reader :node, :directory
9
+ def initialize(config = Design.config)
10
+ @node = config.fetch("node")
11
+ @directory = File.expand_path(config.fetch("runtime_dir"))
12
+ end
13
+ def script(name)
14
+ File.join(Design.gem_root, "browser", name)
15
+ end
16
+ def capture(*argv, timeout:)
17
+ output = [String.new, String.new]
18
+ status = nil
19
+ Open3.popen3(*argv, pgroup: true) do |stdin, stdout, stderr, waiter|
20
+ stdin.close
21
+ readers = [stdout, stderr].each_with_index.map do |stream, i|
22
+ Thread.new do
23
+ loop do
24
+ chunk = stream.readpartial(16_384)
25
+ remaining = 262_144 - output[i].bytesize
26
+ output[i] << chunk.byteslice(0, remaining) if remaining.positive?
27
+ end
28
+ rescue EOFError, IOError
29
+ nil
30
+ end
31
+ end
32
+ begin
33
+ unless waiter.join(timeout)
34
+ raise Error, "Command #{argv.first.inspect} timed out after #{timeout}s"
35
+ end
36
+ status = waiter.value
37
+ ensure
38
+ # A completed command can leave descendants holding its pipes. Their
39
+ # lifetime belongs to this capture, not to the caller's next command.
40
+ Thread.handle_interrupt(Interrupt => :never) do
41
+ Ace::Core::Atoms::ProcessTerminator.terminate(-waiter.pid)
42
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
43
+ readers.each do |reader|
44
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ reader.join([remaining, 0].max)
46
+ end
47
+ [stdout, stderr].each { |io| io.close unless io.closed? }
48
+ readers.each { |reader| reader.kill if reader.alive? }
49
+ readers.each { |reader| reader.join(0.1) }
50
+ end
51
+ end
52
+ end
53
+ {"stdout" => output[0], "stderr" => output[1], "exit_code" => status.exitstatus}
54
+ rescue Errno::ENOENT
55
+ raise Error, "Command executable unavailable: #{argv.first}"
56
+ end
57
+ def doctor
58
+ result = capture(node, script("doctor.mjs"), directory, timeout: 15)
59
+ data = JSON.parse(result["stdout"])
60
+ data["runtime_dir"] = directory
61
+ data["manifest_dir"] = File.join(Design.gem_root, "browser")
62
+ data
63
+ rescue Error, JSON::ParserError => e
64
+ {"ready" => false, "error" => e.message, "runtime_dir" => directory,
65
+ "manifest_dir" => File.join(Design.gem_root, "browser")}
66
+ end
67
+ def preview(session, port: 0)
68
+ Kernel.exec(node, script("preview.mjs"), session.root, port.to_s)
69
+ rescue Errno::ENOENT
70
+ raise Error, "Preview requires Node 22+; configured executable: #{node}"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+ require "cgi"
4
+ require "pathname"
5
+ module Ace
6
+ module Design
7
+ module Molecules
8
+ class Session
9
+ attr_reader :root, :metadata, :fixtures
10
+ def initialize(path)
11
+ @root = File.realpath(path)
12
+ raise Error, "session must be a directory" unless File.directory?(root)
13
+ @metadata = read_json("session.json")
14
+ Atoms::Contracts.surface(metadata["surface"])
15
+ Atoms::Contracts.text(metadata["title"], "session title")
16
+ Atoms::Contracts.text(metadata["language"], "session language")
17
+ ids = metadata["variants"]
18
+ unless ids.is_a?(Array) && !ids.empty? && ids.uniq == ids &&
19
+ ids.all? { |id| id.is_a?(String) && Preset::ID.match?(id) }
20
+ raise Error, "session variants must be a nonempty list of unique safe IDs"
21
+ end
22
+ @fixtures = read_json("fixtures.json")
23
+ safe_file("brief.md")
24
+ rescue Errno::ENOENT, Errno::ENOTDIR => e
25
+ raise Error, "Session missing or incomplete: #{e.message}"
26
+ end
27
+ def safe_file(relative)
28
+ path = File.realpath(File.join(root, relative))
29
+ unless path.start_with?(root + File::SEPARATOR) && File.file?(path)
30
+ raise Error, "Session file escapes session root: #{relative}"
31
+ end
32
+ path
33
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ELOOP
34
+ raise Error, "Session file missing or broken symlink: #{relative}"
35
+ end
36
+ def read_json(relative)
37
+ Atoms::Contracts.object(JSON.parse(File.read(safe_file(relative))), relative)
38
+ rescue JSON::ParserError => e
39
+ raise Error, "Invalid #{relative}: #{e.message}"
40
+ end
41
+ def variants
42
+ metadata.fetch("variants").select do |id|
43
+ directory = File.join(root, "variants", id)
44
+ next false unless File.directory?(directory)
45
+ next false unless File.file?(File.join(directory, "direction.md"))
46
+ Artifacts.files(directory)
47
+ artifact = Artifacts.descriptor(directory)
48
+ safe_file("variants/#{id}/#{artifact.fetch('entry')}") unless artifact["type"] == "url"
49
+ safe_file("variants/#{id}/direction.md")
50
+ true
51
+ end
52
+ end
53
+ def validate(allow_empty: false)
54
+ available = variants
55
+ raise Error, "No ready variants: add variants/#{metadata.fetch("variants").first}/index.html and direction.md" if available.empty? && !allow_empty
56
+ selection = File.join(root, "selection.json")
57
+ validate_selection if File.exist?(selection) || File.symlink?(selection)
58
+ {"surface" => metadata["surface"], "variants" => available,
59
+ "missing_variants" => metadata.fetch("variants") - available, "selected" => File.exist?(selection)}
60
+ end
61
+ def validate_selection
62
+ chosen = read_json("selection.json")
63
+ unless chosen["surface"] == metadata["surface"] && variants.include?(chosen["variant"])
64
+ raise Error, "Selection must name an available variant and the session surface"
65
+ end
66
+ expected = Digest::SHA256.file(safe_file("fixtures.json")).hexdigest
67
+ raise Error, "Selection is stale: fixtures changed; confirm the direction again" unless chosen["fixtures_sha256"] == expected
68
+ if chosen["variant_sha256"] != Artifacts.digest(File.join(root, "variants", chosen["variant"]))
69
+ raise Error, "Selection is stale: variant changed; confirm the direction again"
70
+ end
71
+ decisions = chosen["decisions"]
72
+ unless decisions.is_a?(Array) && !decisions.empty? && decisions.all? { |s| s.is_a?(String) && !s.strip.empty? }
73
+ raise Error, "Selection decisions must be a nonempty list of text"
74
+ end
75
+ chosen
76
+ end
77
+ def write_gallery
78
+ result = validate(allow_empty: true)
79
+ path = File.join(root, "gallery.html")
80
+ raise Error, "Refusing symlink gallery.html" if File.symlink?(path)
81
+ cards = result["variants"].map do |id|
82
+ direction = CGI.escapeHTML(File.read(safe_file("variants/#{id}/direction.md")))
83
+ artifact = Artifacts.descriptor(File.join(root, "variants", id))
84
+ entry = artifact.fetch("entry")
85
+ url = CGI.escapeHTML(artifact["type"] == "url" ? entry : "variants/#{id}/#{entry}")
86
+ visual = case artifact["type"]
87
+ when "html" then %(<div class="frame"><iframe title="Direction #{id.upcase}" src="#{url}" sandbox="allow-scripts"></iframe></div>)
88
+ when "image" then %(<img src="#{url}" alt="Direction #{id.upcase}" style="max-width:100%">)
89
+ else %(<p>External result — open the provider preview. Remote content may change independently.</p>)
90
+ end
91
+ %(<section><h2>#{id.upcase} <a href="#{url}" target="_blank" rel="noopener noreferrer">Open #{id.upcase}</a></h2><p>#{artifact.fetch('type')}</p>#{visual}<details><summary>Direction notes</summary><p class="direction">#{direction}</p></details></section>)
92
+ end.join("\n")
93
+ title = CGI.escapeHTML(metadata["title"])
94
+ run = File.file?(File.join(root, "run.json")) ? read_json("run.json") : nil
95
+ missing = result["missing_variants"].empty? ? "" : "Still preparing: #{result['missing_variants'].join(', ')}"
96
+ if run && !result["missing_variants"].empty?
97
+ labels = result["missing_variants"].map { |id| "#{id} (#{run.dig('workers', id, 'status') || 'pending'})" }
98
+ missing = CGI.escapeHTML("Not ready: #{labels.join(', ')}")
99
+ end
100
+ refresh = run && run["status"] == "running" ? '<meta http-equiv="refresh" content="3">' : ""
101
+ html = <<~HTML
102
+ <!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
103
+ <title>#{title} — directions</title>#{refresh}<style>
104
+ *{box-sizing:border-box}body{margin:0;padding:24px;background:#f5f5f5;color:#171717;font:16px system-ui}
105
+ main{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:24px}
106
+ section{min-width:0}h2 a{font-size:16px;font-weight:normal;margin-left:12px}.frame{overflow:hidden;border:1px solid #ccc;background:white}iframe{display:block;width:1200px;height:900px;border:0;transform-origin:top left}details{margin-top:12px}
107
+ .direction{white-space:pre-wrap}a{color:#174dad}header{max-width:85ch;margin-bottom:24px}
108
+ </style><header><h1>#{title}</h1><p>Compare directions. Give feedback in chat or with ace-design revise using a variant ID. These are mockups.</p><p>#{missing}</p><label>Preview width <select id="width"><option value="1200">Desktop</option><option value="768">Tablet</option><option value="390">Mobile</option></select></label></header><main>#{cards}</main>
109
+ <script>
110
+ const select=document.getElementById('width');
111
+ function size(frame){const width=Number(select.value),scale=frame.clientWidth/width,iframe=frame.querySelector('iframe');iframe.style.width=width+'px';iframe.style.transform='scale('+scale+')';frame.style.height=(900*scale)+'px';}
112
+ const frames=[...document.querySelectorAll('.frame')];
113
+ const observer=new ResizeObserver(entries=>entries.forEach(entry=>size(entry.target)));
114
+ frames.forEach(frame=>observer.observe(frame));select.addEventListener('change',()=>frames.forEach(size));
115
+ </script></html>
116
+ HTML
117
+ File.write(path, html)
118
+ path
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+ require "tmpdir"
3
+ module Ace
4
+ module Design
5
+ module Molecules
6
+ class SkillSources
7
+ def initialize(config = Design.config)
8
+ @config = config
9
+ end
10
+ def requirements(preset)
11
+ preset.fetch("variants").flat_map do |slot|
12
+ Array(slot["requirements"]).map do |requirement|
13
+ raise Error, "Requirements must be objects with a name" unless requirement.is_a?(Hash) && requirement["name"]
14
+ executable = requirement["executable"]
15
+ available = executable && ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |path|
16
+ file = File.join(path, executable)
17
+ File.file?(file) && File.executable?(file)
18
+ end
19
+ requirement.merge("variant" => slot.fetch("id"), "status" => executable ? (available ? "available" : "missing") : "agent_check")
20
+ end
21
+ end
22
+ end
23
+ def copy_tree(source, target, boundary, ancestors)
24
+ actual = File.realpath(source)
25
+ unless actual == boundary || actual.start_with?(boundary + "/")
26
+ raise Error, "Skill resource link escapes pinned repository: #{source}"
27
+ end
28
+ if File.directory?(actual)
29
+ raise Error, "Cyclic skill resource link: #{source}" if ancestors.include?(actual)
30
+ FileUtils.mkdir_p(target)
31
+ Dir.children(actual).each { |name| copy_tree(File.join(actual, name), File.join(target, name), boundary, ancestors + [actual]) }
32
+ elsif File.file?(actual)
33
+ FileUtils.cp(actual, target, preserve: true)
34
+ else
35
+ raise Error, "Unsupported skill resource: #{source}"
36
+ end
37
+ end
38
+ def snapshot(root, preset)
39
+ preset.fetch("variants").each do |slot|
40
+ Array(slot["skills"]).each_with_index do |spec, index|
41
+ revision = spec.fetch("revision")
42
+ raise Error, "Skill revision must be a full commit SHA" unless /\A[0-9a-f]{40}\z/.match?(revision)
43
+ target = File.join(root, "skills", slot.fetch("id"), index.to_s)
44
+ FileUtils.mkdir_p(target)
45
+ Dir.mktmpdir("ace-design-skill") do |checkout|
46
+ runtime = Runtime.new(@config)
47
+ commands = [["git", "init", "--quiet", checkout],
48
+ ["git", "-C", checkout, "fetch", "--quiet", "--depth=1", spec.fetch("source"), revision],
49
+ ["git", "-C", checkout, "checkout", "--quiet", "--detach", "FETCH_HEAD"]]
50
+ commands.each do |command|
51
+ result = runtime.capture(*command, timeout: 120)
52
+ raise Error, "Skill fetch failed: #{result['stderr']}" unless result["exit_code"] == 0
53
+ end
54
+ result = runtime.capture("git", "-C", checkout, "rev-parse", "HEAD", timeout: 10)
55
+ raise Error, "Skill revision mismatch" unless result["stdout"].strip == revision
56
+ FileUtils.rm_rf(File.join(checkout, ".git"))
57
+ copy_tree(checkout, target, File.realpath(checkout), [])
58
+ end
59
+ entry = spec.fetch("entry")
60
+ resolved = File.expand_path(entry, target)
61
+ unless resolved.start_with?(target + "/") && File.file?(resolved)
62
+ raise Error, "Missing skill entry: #{entry}"
63
+ end
64
+ spec["snapshot"] = "skills/#{slot.fetch('id')}/#{index}/#{entry}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end