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.
- checksums.yaml +4 -4
- data/README.md +32 -267
- data/lib/space_architect/architect_project.rb +90 -39
- data/lib/space_architect/cli/architect.rb +169 -4
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/loop_cost.rb +592 -0
- data/lib/space_architect/research/supervisor.rb +9 -2
- data/lib/space_architect/routing.rb +149 -0
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +1 -0
- metadata +3 -1
|
@@ -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
|
data/lib/space_architect.rb
CHANGED
|
@@ -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.
|
|
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
|