space-architect 9.0.0 → 9.1.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.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Space::Architect
7
+ # The v1 `architect.yaml` loop-role routing contract — the parity twin of the
8
+ # pi extension's parser (the extension's behavior defines v1; the one
9
+ # deliberate addition this iteration is `thinking` VALUE validation, landed on
10
+ # both sides). Pure module: parse one file's text, resolve the merged routing
11
+ # for a space, nil when no config exists anywhere. Every invalid state raises
12
+ # Space::Core::Error in the CLI's idiom, naming the file, the key, and the
13
+ # expectation. Resolution layers are [global, space-local], merged per role
14
+ # key; global is ENV["SPACE_ARCHITECT_GLOBAL_CONFIG"] if set, else
15
+ # ~/.config/space-architect/architect.yaml (exactly the extension's
16
+ # resolution — parity outranks the XDG convention, do not divert to
17
+ # XDG_CONFIG_HOME); space-local is architect.yaml beside the loaded
18
+ # space.yaml (the CLI already resolved the space — no walk-up).
19
+ module Routing
20
+ ROLES = %w[architect spec builder judge coordinator].freeze
21
+ # The four agent roles that must be routed after the merge; `architect` is optional.
22
+ REQUIRED_ROLES = %w[spec builder judge coordinator].freeze
23
+ ENTRY_KEYS = %w[model thinking].freeze
24
+
25
+ def self.global_path
26
+ env = ENV["SPACE_ARCHITECT_GLOBAL_CONFIG"]
27
+ return Pathname.new(env) unless env.nil?
28
+ Pathname.new("~/.config/space-architect/architect.yaml").expand_path
29
+ end
30
+
31
+ def self.space_path(space_dir)
32
+ Pathname.new(space_dir).join("architect.yaml")
33
+ end
34
+
35
+ # Parse one file's text into { role => {"model" => id, "thinking" => level|nil} }.
36
+ # +path+ is used only in error messages. Frozen v1 rules: integer version 1,
37
+ # the five known roles, entries ⊆ {model, thinking} with a non-empty model,
38
+ # duplicate mapping keys rejected (psych's plain load silently keeps the
39
+ # last — the npm yaml the extension parses with throws), aliases accepted,
40
+ # a trailing :level suffix on model parsed via Harness.parse_model_suffix
41
+ # and beating the thinking key.
42
+ def self.parse(text, path)
43
+ assert_unique_keys!(text, path)
44
+ doc = YAML.safe_load(text, aliases: true)
45
+ unless doc.is_a?(Hash)
46
+ raise Space::Core::Error, "architect.yaml (#{path}): <top> — expected a YAML mapping, got #{doc.inspect}"
47
+ end
48
+ version = doc["version"]
49
+ unless version.is_a?(Integer) && version == 1
50
+ raise Space::Core::Error, "architect.yaml (#{path}): version — expected integer 1, got #{version.inspect}"
51
+ end
52
+ roles = doc["roles"]
53
+ unless roles.is_a?(Hash)
54
+ raise Space::Core::Error, "architect.yaml (#{path}): roles — expected a mapping of role → entry, got #{roles.inspect}"
55
+ end
56
+
57
+ parsed = {}
58
+ roles.each do |role, entry|
59
+ unless ROLES.include?(role)
60
+ raise Space::Core::Error,
61
+ "architect.yaml (#{path}): roles.#{role} — expected one of #{ROLES.join('|')}, got #{role.inspect}"
62
+ end
63
+ unless entry.is_a?(Hash)
64
+ raise Space::Core::Error,
65
+ "architect.yaml (#{path}): roles.#{role} — expected a {model, thinking?} mapping, got #{entry.inspect}"
66
+ end
67
+ entry.each_key do |entry_key|
68
+ unless ENTRY_KEYS.include?(entry_key)
69
+ raise Space::Core::Error,
70
+ "architect.yaml (#{path}): roles.#{role}.#{entry_key} — expected one of #{ENTRY_KEYS.join('|')}, got #{entry_key.inspect}"
71
+ end
72
+ end
73
+ raw_model = entry["model"]
74
+ unless raw_model.is_a?(String) && !raw_model.strip.empty?
75
+ raise Space::Core::Error,
76
+ "architect.yaml (#{path}): roles.#{role}.model — expected non-empty 'provider/model-id[:level]', got #{raw_model.inspect}"
77
+ end
78
+ model, suffix_level = Harness.parse_model_suffix(raw_model.strip)
79
+
80
+ thinking = nil
81
+ if entry.key?("thinking")
82
+ raw_thinking = entry["thinking"]
83
+ # psych implements YAML 1.1, where a bare `off` is boolean false; the
84
+ # extension's YAML 1.2 parser reads it as the string "off". Coerce so
85
+ # `thinking: off` parses identically on both sides.
86
+ raw_thinking = "off" if raw_thinking == false
87
+ unless raw_thinking.is_a?(String) && Harness::THINKING_LEVELS.include?(raw_thinking.strip)
88
+ raise Space::Core::Error,
89
+ "architect.yaml (#{path}): roles.#{role}.thinking — expected one of " \
90
+ "#{Harness::THINKING_LEVELS.join('|')}, got #{entry['thinking'].inspect}"
91
+ end
92
+ thinking = raw_thinking.strip
93
+ end
94
+ parsed[role] = { "model" => model, "thinking" => suffix_level || thinking }
95
+ end
96
+ parsed
97
+ rescue Psych::Exception => e
98
+ raise Space::Core::Error, "architect.yaml (#{path}): malformed YAML — #{e.message}"
99
+ end
100
+
101
+ # The merged routing for a space: [global, space-local] merged per role key
102
+ # (a space file may override only builder and inherit the rest). nil when
103
+ # NEITHER file exists — no config is a normal state: no error, no side
104
+ # effects. Any found file that is malformed/invalid fails loud at parse;
105
+ # after the merge all four agent roles must be routed.
106
+ def self.for_space(space_dir)
107
+ global = global_path
108
+ local = space_path(space_dir)
109
+ found = [global, local].select { |p| File.file?(p.to_s) }
110
+ return nil if found.empty?
111
+
112
+ merged = {}
113
+ found.each { |p| merged.merge!(parse(File.read(p.to_s), p)) }
114
+ missing = REQUIRED_ROLES - merged.keys
115
+ if missing.any?
116
+ raise Space::Core::Error,
117
+ "architect.yaml (#{found.join(' + ')}): roles.#{missing.first} — expected all agent roles " \
118
+ "#{REQUIRED_ROLES.join('|')} to be routed, got (missing)"
119
+ end
120
+ merged
121
+ end
122
+
123
+ # Duplicate mapping keys anywhere in the document fail loud. Walks the psych
124
+ # node tree (a second, cheap parse) because YAML.safe_load cannot report them.
125
+ # An empty document parses to `false` (no root node) — skip the walk and let
126
+ # safe_load surface it as the loud not-a-mapping error.
127
+ def self.assert_unique_keys!(text, path)
128
+ tree = Psych.parse(text, filename: path.to_s)
129
+ return if tree == false
130
+ walk_unique_keys(tree.root, path)
131
+ end
132
+
133
+ def self.walk_unique_keys(node, path)
134
+ return unless node.is_a?(Psych::Nodes::Mapping) || node.is_a?(Psych::Nodes::Sequence)
135
+ if node.is_a?(Psych::Nodes::Mapping)
136
+ seen = {}
137
+ node.children.each_slice(2) do |key, _value| # paths:exempt — psych AST walk, not filesystem enumeration
138
+ next unless key.is_a?(Psych::Nodes::Scalar)
139
+ if seen[key.value]
140
+ raise Space::Core::Error,
141
+ "architect.yaml (#{path}): duplicate mapping key '#{key.value}' — map keys must be unique"
142
+ end
143
+ seen[key.value] = true
144
+ end
145
+ end
146
+ node.children.each { |child| walk_unique_keys(child, path) } # paths:exempt — psych AST walk, not filesystem enumeration
147
+ end
148
+ end
149
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Architect
5
- VERSION = "9.0.0"
5
+ VERSION = "9.1.0"
6
6
  end
7
7
  end
@@ -4,6 +4,7 @@ require "space_core"
4
4
  require_relative "space_architect/version"
5
5
 
6
6
  require_relative "space_architect/harness"
7
+ require_relative "space_architect/routing"
7
8
  require_relative "space_architect/run_creator"
8
9
  require_relative "space_architect/gate_lint"
9
10
  require_relative "space_architect/gate_evaluator"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: space-architect
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.0.0
4
+ version: 9.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -190,6 +190,7 @@ files:
190
190
  - lib/space_architect/gate_lint.rb
191
191
  - lib/space_architect/harness.rb
192
192
  - lib/space_architect/jobs_client.rb
193
+ - lib/space_architect/loop_cost.rb
193
194
  - lib/space_architect/pi/builder-guard.ts
194
195
  - lib/space_architect/research.rb
195
196
  - lib/space_architect/research/mux.rb
@@ -198,6 +199,7 @@ files:
198
199
  - lib/space_architect/research/renderer.rb
199
200
  - lib/space_architect/research/run.rb
200
201
  - lib/space_architect/research/supervisor.rb
202
+ - lib/space_architect/routing.rb
201
203
  - lib/space_architect/run_creator.rb
202
204
  - lib/space_architect/session_sync.rb
203
205
  - lib/space_architect/session_sync/bin_path.rb