space-architect 9.6.0 → 9.8.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/CHANGELOG.md +40 -0
- data/lib/space_architect/agent_installer.rb +18 -4
- data/lib/space_architect/architect_project.rb +306 -9
- data/lib/space_architect/cli/architect.rb +60 -2
- data/lib/space_architect/iteration_emission.rb +325 -0
- data/lib/space_architect/routing.rb +97 -48
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +1 -0
- data/skill/architect/SKILL.md +8 -6
- data/skill/architect-action/SKILL.md +22 -28
- data/skill/architect-discuss/SKILL.md +15 -15
- data/skill/architect-discuss/dispatch.md +10 -18
- metadata +2 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "json"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require "pathname"
|
|
8
|
+
|
|
9
|
+
module Space::Architect
|
|
10
|
+
# I16 — the structured-record emission layer: config resolution (session-sink
|
|
11
|
+
# mirror: unconfigured = fully inert, configured = capture on, mute knob
|
|
12
|
+
# available), the best-effort Net::HTTP client, the payload builders that
|
|
13
|
+
# derive from the canonical sources the loop commands already hold, and the
|
|
14
|
+
# ```rulings block parser. ArchitectProject's five hook call sites guard
|
|
15
|
+
# through `emitter&.post` inside a rescue-all belt; nothing here may ever
|
|
16
|
+
# raise into a loop command or change its exit code, stdout, or commits.
|
|
17
|
+
module IterationEmission
|
|
18
|
+
# Frozen tuning (mirrors the spec; session-sink pins its own constants the
|
|
19
|
+
# same way): per attempt 2s open / 5s read, at most 2 attempts, retry on
|
|
20
|
+
# network errors or 5xx ONLY — 4xx is never retried.
|
|
21
|
+
OPEN_TIMEOUT = 2
|
|
22
|
+
READ_TIMEOUT = 5
|
|
23
|
+
MAX_ATTEMPTS = 2
|
|
24
|
+
|
|
25
|
+
# The four server mouths (I15, frozen): POST <host>/iterations/ingest/<mouth>.
|
|
26
|
+
MOUTHS = %w[freeze lane verdict integration].freeze
|
|
27
|
+
|
|
28
|
+
# Iteration/lane status vocabularies (frozen): nothing else is emitted.
|
|
29
|
+
ITERATION_STATUSES = %w[frozen integrated].freeze
|
|
30
|
+
LANE_STATUSES = %w[complete integrated].freeze
|
|
31
|
+
|
|
32
|
+
# The space-slug rule (architect_project.rb :1642), with the emission-layer
|
|
33
|
+
# fallback: space.data["id"] when the title is absent or slugifies empty.
|
|
34
|
+
def self.slug_for(space)
|
|
35
|
+
slug = space.title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
|
|
36
|
+
slug.empty? ? space.data["id"].to_s : slug
|
|
37
|
+
rescue StandardError
|
|
38
|
+
space.data["id"].to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ---- config resolution (the Routing.global_path pattern) ---------------
|
|
42
|
+
|
|
43
|
+
# Global file ~/.config/space-architect/emission.yaml, path overridable via
|
|
44
|
+
# SPACE_ARCHITECT_EMISSION_GLOBAL_CONFIG — exactly Routing.global_path's
|
|
45
|
+
# shape (env pin first, else the ~/.config path; parity outranks XDG).
|
|
46
|
+
def self.global_path(env: ENV)
|
|
47
|
+
override = env["SPACE_ARCHITECT_EMISSION_GLOBAL_CONFIG"]
|
|
48
|
+
return Pathname.new(override) unless override.nil?
|
|
49
|
+
|
|
50
|
+
Pathname.new("~/.config/space-architect/emission.yaml").expand_path
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Resolve the configured Emitter for a space, or nil when unconfigured:
|
|
54
|
+
# no file, missing host/token, or disabled. ENV beats the file per field,
|
|
55
|
+
# both ways: SPACE_ARCHITECT_EMISSION_HOST / _TOKEN override their file
|
|
56
|
+
# values; _DISABLED ("1"/"true" mutes; any other present value unmutes)
|
|
57
|
+
# overrides the file's disabled knob in both directions. A malformed or
|
|
58
|
+
# unreadable file is INERT PLUS one stderr line naming the path and the
|
|
59
|
+
# problem; an absent file is inert and silent (a no-config machine is a
|
|
60
|
+
# normal state — zero output, zero network).
|
|
61
|
+
def self.emitter_for(space, env: ENV, err: $stderr)
|
|
62
|
+
config = read_config(global_path(env: env), err: err)
|
|
63
|
+
disabled_env = env["SPACE_ARCHITECT_EMISSION_DISABLED"]
|
|
64
|
+
host = env["SPACE_ARCHITECT_EMISSION_HOST"] || config&.[](:host)
|
|
65
|
+
token = env["SPACE_ARCHITECT_EMISSION_TOKEN"] || config&.[](:token)
|
|
66
|
+
disabled =
|
|
67
|
+
if disabled_env.nil?
|
|
68
|
+
config&.[](:disabled) == true
|
|
69
|
+
else
|
|
70
|
+
%w[1 true].include?(disabled_env.strip.downcase)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return nil if disabled || host.to_s.strip.empty? || token.to_s.strip.empty?
|
|
74
|
+
|
|
75
|
+
Emitter.new(host: host.to_s.strip, token: token.to_s, slug: slug_for(space))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.read_config(path, err:)
|
|
79
|
+
return nil unless File.file?(path.to_s)
|
|
80
|
+
|
|
81
|
+
doc = YAML.safe_load(File.read(path.to_s), aliases: true)
|
|
82
|
+
unless doc.is_a?(Hash)
|
|
83
|
+
err.puts "emission config (#{path}): expected a YAML mapping, got #{doc.inspect} — file config ignored"
|
|
84
|
+
return nil
|
|
85
|
+
end
|
|
86
|
+
{
|
|
87
|
+
host: doc["host"], token: doc["token"], disabled: doc["disabled"] == true
|
|
88
|
+
}
|
|
89
|
+
rescue Psych::Exception => e
|
|
90
|
+
err.puts "emission config (#{path}): malformed YAML — #{e.message} — file config ignored"
|
|
91
|
+
nil
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
err.puts "emission config (#{path}): unreadable — #{e.class}: #{e.message} — file config ignored"
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ---- the client ---------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
# One best-effort POST per call to <host>/iterations/ingest/<mouth>.
|
|
100
|
+
# NEVER raises into the caller: at most MAX_ATTEMPTS attempts (retry on
|
|
101
|
+
# network errors or 5xx only), then exactly one stderr line — the mouth
|
|
102
|
+
# path plus the failure class/status with a bounded response excerpt.
|
|
103
|
+
# Never the token, never the payload. On 2xx returns
|
|
104
|
+
# {status:, body:} (body JSON-parsed when possible).
|
|
105
|
+
class Emitter
|
|
106
|
+
attr_reader :host, :token, :slug
|
|
107
|
+
|
|
108
|
+
def initialize(host:, token:, slug:)
|
|
109
|
+
@host = host.sub(%r{/+\z}, "") # trailing slashes stripped once
|
|
110
|
+
@token = token
|
|
111
|
+
@slug = slug
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def post(mouth, payload)
|
|
115
|
+
path = "/iterations/ingest/#{mouth}"
|
|
116
|
+
body = JSON.generate(payload)
|
|
117
|
+
response = nil
|
|
118
|
+
last_error = nil
|
|
119
|
+
MAX_ATTEMPTS.times do
|
|
120
|
+
begin
|
|
121
|
+
response, last_error = post_once(path, body)
|
|
122
|
+
rescue *NETWORK_ERRORS => e
|
|
123
|
+
last_error = e # network failure: fall through to the retry
|
|
124
|
+
end
|
|
125
|
+
break if response && response.code.to_i.between?(200, 499)
|
|
126
|
+
# 5xx and network failures fall through to the retry; 4xx breaks —
|
|
127
|
+
# never retried.
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
return result_for(response) if response&.code.to_i.between?(200, 299)
|
|
131
|
+
|
|
132
|
+
report_failure(path, response, last_error)
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def post_once(path, body)
|
|
139
|
+
uri = URI.parse("#{@host}#{path}")
|
|
140
|
+
uri.scheme ||= "http"
|
|
141
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
142
|
+
http.use_ssl = uri.scheme == "https"
|
|
143
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
144
|
+
http.read_timeout = READ_TIMEOUT
|
|
145
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
146
|
+
request["Content-Type"] = "application/json"
|
|
147
|
+
request["Authorization"] = "Bearer #{@token}"
|
|
148
|
+
request["X-Space-Slug"] = @slug
|
|
149
|
+
request.body = body
|
|
150
|
+
response = http.request(request)
|
|
151
|
+
[response, nil]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
NETWORK_ERRORS = [
|
|
155
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
|
|
156
|
+
Errno::EPIPE, Errno::EACCES, Errno::EADDRNOTAVAIL, Errno::ETIMEDOUT,
|
|
157
|
+
SocketError, IOError, EOFError, SystemCallError,
|
|
158
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse,
|
|
159
|
+
OpenSSL::SSL::SSLError, Timeout::Error
|
|
160
|
+
].freeze
|
|
161
|
+
|
|
162
|
+
def result_for(response)
|
|
163
|
+
{ status: response.code.to_i, body: safe_parse(response.body) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def safe_parse(raw)
|
|
167
|
+
JSON.parse(raw.to_s)
|
|
168
|
+
rescue JSON::ParserError, ArgumentError
|
|
169
|
+
raw.to_s
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# One stderr line: mouth path + failure class/status + bounded response
|
|
173
|
+
# excerpt when one arrived. The token lives only in the Authorization
|
|
174
|
+
# header; the payload is never echoed; a response body is server-written
|
|
175
|
+
# text and never echoes either.
|
|
176
|
+
def report_failure(path, response, last_error)
|
|
177
|
+
detail =
|
|
178
|
+
if response
|
|
179
|
+
"status #{response.code} — #{response.body.to_s[0, 200]}"
|
|
180
|
+
else
|
|
181
|
+
"#{last_error&.class || 'network failure'}"
|
|
182
|
+
end
|
|
183
|
+
$stderr.puts "#{path}: emission failed after #{MAX_ATTEMPTS} attempt(s): #{detail}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# ---- payload builders (contract keys only; nil/empty scalars omitted) --
|
|
188
|
+
#
|
|
189
|
+
# The server's frozen contracts (repos/all-fax-no-printer
|
|
190
|
+
# app/contracts/ingest_iteration_*.rb) 422 unknown keys and empty `expect`
|
|
191
|
+
# maps — builders emit ONLY contract keys and omit `expect` ENTIRELY when
|
|
192
|
+
# the gate declares none (never {}).
|
|
193
|
+
|
|
194
|
+
def self.compact_scalars(hash)
|
|
195
|
+
hash.reject { |_k, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# One gate as the freeze mouth stores it: id/ac/cmd/cwd/timeout/expect,
|
|
199
|
+
# nil fields omitted, expect dropped entirely when absent or empty.
|
|
200
|
+
def self.map_gate(g)
|
|
201
|
+
g = g.transform_keys(&:to_s)
|
|
202
|
+
compact_scalars(
|
|
203
|
+
"id" => g["id"], "ac" => g["ac"], "cmd" => g["cmd"], "cwd" => g["cwd"],
|
|
204
|
+
"timeout" => g["timeout"], "expect" => (g["expect"].is_a?(Hash) && !g["expect"].empty? ? g["expect"] : nil)
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.build_freeze(ordinal:, name:, freeze_sha:, branch:, gates:)
|
|
209
|
+
compact_scalars(
|
|
210
|
+
"iteration" => compact_scalars(
|
|
211
|
+
"ordinal" => ordinal, "name" => name, "freeze_sha" => freeze_sha,
|
|
212
|
+
"branch" => branch, "status" => "frozen"
|
|
213
|
+
),
|
|
214
|
+
"gates" => Array(gates).map { |g| map_gate(g) }
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Lane-mouth scalars as stored in the space.yaml lane record, re-sliced
|
|
219
|
+
# from it — never a hand-maintained copy. status/gate_results ride when
|
|
220
|
+
# given (nil/empty omitted; an absent key leaves stored rows untouched).
|
|
221
|
+
def self.build_lane(ordinal:, name:, lane_record:, status: nil, gate_results: nil)
|
|
222
|
+
r = lane_record.transform_keys(&:to_s)
|
|
223
|
+
lane = compact_scalars(
|
|
224
|
+
"name" => r["name"], "repo" => r["repo"], "base_sha" => r["base_sha"],
|
|
225
|
+
"model" => r["model"], "harness" => r["harness"], "effort" => r["effort"],
|
|
226
|
+
"touch_set" => (Array(r["touch_set"]) unless Array(r["touch_set"]).empty?),
|
|
227
|
+
"bounds_override_reason" => r["bounds_override_reason"],
|
|
228
|
+
"status" => status,
|
|
229
|
+
"gate_results" => gate_results
|
|
230
|
+
)
|
|
231
|
+
{ "iteration" => compact_scalars("ordinal" => ordinal, "name" => name), "lane" => lane }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def self.map_gate_result(g, transcript)
|
|
235
|
+
result = compact_scalars(
|
|
236
|
+
"gate_id" => g[:gate_id], "status" => g[:status].to_s,
|
|
237
|
+
"reason" => g[:reason].to_s, "transcript" => transcript
|
|
238
|
+
)
|
|
239
|
+
result.delete("reason") if g[:reason].to_s.empty?
|
|
240
|
+
result
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# A gate transcript sidecar entry: the structured mirror of one executed
|
|
244
|
+
# gate (all keys always present — it is the faithful machine mirror).
|
|
245
|
+
def self.map_sidecar_gate(g)
|
|
246
|
+
{
|
|
247
|
+
"gate_id" => g[:id].to_s, "status" => g[:status].to_s, "reason" => g[:reason].to_s,
|
|
248
|
+
"stdout" => g[:stdout].to_s, "stderr" => g[:stderr].to_s,
|
|
249
|
+
"exit_code" => g[:exit_code], "cmd" => g[:cmd].to_s, "dir" => g[:dir].to_s
|
|
250
|
+
}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def self.build_verdict(ordinal:, name:, decision:, rulings:)
|
|
254
|
+
verdict = compact_scalars(
|
|
255
|
+
"decision" => decision,
|
|
256
|
+
"ac_rulings" => (rulings[:ac_rulings] if rulings[:ac_rulings]&.any?),
|
|
257
|
+
"phase0_resolutions" => (rulings[:phase0_resolutions] if rulings[:phase0_resolutions]&.any?)
|
|
258
|
+
)
|
|
259
|
+
{ "iteration" => compact_scalars("ordinal" => ordinal, "name" => name), "verdict" => verdict }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def self.build_integration(ordinal:, name:, branch:, lanes:)
|
|
263
|
+
{
|
|
264
|
+
"iteration" => compact_scalars("ordinal" => ordinal, "name" => name, "status" => "integrated"),
|
|
265
|
+
"integration" => {
|
|
266
|
+
"branch" => branch,
|
|
267
|
+
"lanes" => lanes.map { |l| compact_scalars("name" => l[:name], "merge_sha" => l[:merge_sha], "integration_branch" => l[:integration_branch]) }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# ---- the ```rulings block (S3) ------------------------------------------
|
|
273
|
+
#
|
|
274
|
+
# A fenced YAML block inside the ## Verdict body — the ```gates/```lanes
|
|
275
|
+
# discipline applied to verdict detail. Valid entries ride the emission
|
|
276
|
+
# verbatim; ANY invalid entry is dropped with one loud stderr line naming
|
|
277
|
+
# it; a malformed block (bad YAML, wrong shape) is dropped with one line;
|
|
278
|
+
# no block at all is silent decision-only. Extraction never raises.
|
|
279
|
+
|
|
280
|
+
def self.parse_rulings(body, err: $stderr)
|
|
281
|
+
match = body.to_s.match(/^```rulings\n(.*?)^```/m)
|
|
282
|
+
return {} unless match
|
|
283
|
+
|
|
284
|
+
doc = YAML.safe_load(match[1], aliases: true)
|
|
285
|
+
unless doc.is_a?(Hash)
|
|
286
|
+
err.puts "rulings block: expected a YAML mapping, got #{doc.inspect} — dropped (decision-only emission)"
|
|
287
|
+
return {}
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
{
|
|
291
|
+
ac_rulings: valid_rulings(Array(doc["ac_rulings"]), err: err),
|
|
292
|
+
phase0_resolutions: valid_resolutions(Array(doc["phase0_resolutions"]), err: err)
|
|
293
|
+
}
|
|
294
|
+
rescue Psych::Exception, StandardError => e
|
|
295
|
+
err.puts "rulings block: malformed YAML — #{e.message} — dropped (decision-only emission)"
|
|
296
|
+
{}
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# ruling is FREE text (the PASS/FAIL/MODIFY/NOT-APPLICABLE convention is
|
|
300
|
+
# documented, not enforced); only shape is validated here.
|
|
301
|
+
def self.valid_rulings(entries, err:)
|
|
302
|
+
entries.filter_map do |entry|
|
|
303
|
+
unless entry.is_a?(Hash) && entry["ac"].is_a?(String) && !entry["ac"].strip.empty? &&
|
|
304
|
+
entry["ruling"].is_a?(String) && !entry["ruling"].strip.empty? &&
|
|
305
|
+
(entry.key?("note") ? entry["note"].is_a?(String) || entry["note"].nil? : true)
|
|
306
|
+
err.puts "rulings block: dropped invalid ac_rulings entry #{entry.inspect[0, 120]}"
|
|
307
|
+
next
|
|
308
|
+
end
|
|
309
|
+
compact_scalars("ac" => entry["ac"], "ruling" => entry["ruling"], "note" => entry["note"])
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def self.valid_resolutions(entries, err:)
|
|
314
|
+
entries.filter_map do |entry|
|
|
315
|
+
unless entry.is_a?(Hash) && entry["topic"].is_a?(String) && !entry["topic"].strip.empty? &&
|
|
316
|
+
(entry.key?("lane") ? entry["lane"].is_a?(String) || entry["lane"].nil? : true) &&
|
|
317
|
+
(entry.key?("resolution") ? entry["resolution"].is_a?(String) || entry["resolution"].nil? : true)
|
|
318
|
+
err.puts "rulings block: dropped invalid phase0_resolutions entry #{entry.inspect[0, 120]}"
|
|
319
|
+
next
|
|
320
|
+
end
|
|
321
|
+
compact_scalars("topic" => entry["topic"], "lane" => entry["lane"], "resolution" => entry["resolution"])
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -17,48 +17,47 @@ module Space::Architect
|
|
|
17
17
|
# XDG_CONFIG_HOME); space-local is architect.yaml beside the loaded
|
|
18
18
|
# space.yaml (the CLI already resolved the space — no walk-up).
|
|
19
19
|
module Routing
|
|
20
|
-
ROLES = %w[architect spec builder judge
|
|
21
|
-
# The
|
|
22
|
-
REQUIRED_ROLES = %w[spec builder judge
|
|
20
|
+
ROLES = %w[architect spec builder judge narrator].freeze
|
|
21
|
+
# The four agent roles that must be routed after the merge; `architect` is optional.
|
|
22
|
+
REQUIRED_ROLES = %w[spec builder judge narrator].freeze
|
|
23
23
|
ENTRY_KEYS = %w[model thinking].freeze
|
|
24
24
|
|
|
25
25
|
# I09: the `architect routing init` scaffold — the exact bytes the command
|
|
26
26
|
# writes, living beside the parsers that must accept it. Example models are
|
|
27
27
|
# loud REPLACE_MEs; the header comment carries both locations and the merge
|
|
28
|
-
# order so the file is self-documenting. Byte-exact contract: the
|
|
29
|
-
# lane's tests carry an inline copy, and the command round-trips
|
|
30
|
-
# through parse below — a template the machinery rejects is a bug.
|
|
28
|
+
# order so the file is self-documenting. Byte-exact contract (1021 B): the
|
|
29
|
+
# extension lane's tests carry an inline copy, and the command round-trips
|
|
30
|
+
# the write through parse below — a template the machinery rejects is a bug.
|
|
31
|
+
# I10: the detached driver role retired (the architect session drives the
|
|
32
|
+
# loop itself); the scaffold routes the four required roles plus optional architect.
|
|
31
33
|
TEMPLATE = <<~'YAML'.freeze
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
narrator:
|
|
60
|
-
model: example/REPLACE_ME
|
|
61
|
-
thinking: low
|
|
34
|
+
# architect.yaml — space-architect loop role routing, scaffolded by `architect routing init`.
|
|
35
|
+
# Locations (merged in this order; the space-local entry wins per role):
|
|
36
|
+
# global: ~/.config/space-architect/architect.yaml (machine-wide)
|
|
37
|
+
# local: architect.yaml beside this space's space.yaml (this file)
|
|
38
|
+
# A local entry overrides the global entry for THAT ROLE only; after the
|
|
39
|
+
# merge every agent role must be routed: spec, builder, judge, narrator
|
|
40
|
+
# (architect is optional).
|
|
41
|
+
# Replace every example/REPLACE_ME below with a real provider/model-id
|
|
42
|
+
# (e.g. zai/glm-5.3). thinking is optional: off|minimal|low|medium|high|xhigh|max.
|
|
43
|
+
# Once saved, the next /loop or architect command picks the file up.
|
|
44
|
+
version: 1
|
|
45
|
+
roles:
|
|
46
|
+
architect:
|
|
47
|
+
model: example/REPLACE_ME
|
|
48
|
+
thinking: high
|
|
49
|
+
spec:
|
|
50
|
+
model: example/REPLACE_ME
|
|
51
|
+
thinking: high
|
|
52
|
+
builder:
|
|
53
|
+
model: example/REPLACE_ME
|
|
54
|
+
thinking: high
|
|
55
|
+
judge:
|
|
56
|
+
model: example/REPLACE_ME
|
|
57
|
+
thinking: high
|
|
58
|
+
narrator:
|
|
59
|
+
model: example/REPLACE_ME
|
|
60
|
+
thinking: low
|
|
62
61
|
YAML
|
|
63
62
|
|
|
64
63
|
# The one canonical cure line appended to default_model_inform's last-resort
|
|
@@ -75,14 +74,34 @@ module Space::Architect
|
|
|
75
74
|
Pathname.new(space_dir).join("architect.yaml")
|
|
76
75
|
end
|
|
77
76
|
|
|
78
|
-
# Parse one file's text into { role => {"model" => id, "thinking" => level|nil} }
|
|
79
|
-
# +path+ is used only in error messages. Frozen v1 rules:
|
|
80
|
-
# the
|
|
81
|
-
# duplicate mapping keys rejected (psych's plain load silently
|
|
82
|
-
# last — the npm yaml the extension parses with throws), aliases
|
|
83
|
-
# a trailing :level suffix on model parsed via Harness.parse_model_suffix
|
|
84
|
-
# and beating the thinking key.
|
|
77
|
+
# Parse one file's text into { role => {"model" => id, "thinking" => level|nil} }
|
|
78
|
+
# for the KNOWN roles. +path+ is used only in error messages. Frozen v1 rules:
|
|
79
|
+
# integer version 1, the five known roles, entries ⊆ {model, thinking} with a
|
|
80
|
+
# non-empty model, duplicate mapping keys rejected (psych's plain load silently
|
|
81
|
+
# keeps the last — the npm yaml the extension parses with throws), aliases
|
|
82
|
+
# accepted, a trailing :level suffix on model parsed via Harness.parse_model_suffix
|
|
83
|
+
# and beating the thinking key. Unknown role keys (I10 deprecation tolerance: a
|
|
84
|
+
# config still carrying a retired role key, or a typo) are skipped for
|
|
85
|
+
# routing and collected — see split_known_and_unknown.
|
|
85
86
|
def self.parse(text, path)
|
|
87
|
+
split_known_and_unknown(text, path).first
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The pure deprecation seam (I10): the unknown role keys under `roles:` in one
|
|
91
|
+
# file's text, in document order. Same parse rules as
|
|
92
|
+
# parse (known-role validation included); only unknown entries are skipped
|
|
93
|
+
# unvalidated, so the inform never fires for a config the parser rejects.
|
|
94
|
+
def self.unknown_role_keys(text, path)
|
|
95
|
+
split_known_and_unknown(text, path).last
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# One parse, two results: [known-role hash, unknown role keys]. An unknown
|
|
99
|
+
# STRING key is skipped whole for routing — its entry content is never
|
|
100
|
+
# validated (an ignored key is ignored whole; garbage under
|
|
101
|
+
# roles.<retired-role>.thinking must not raise) — and collected for the one
|
|
102
|
+
# deprecation inform. A non-string key is corruption, not a retired role
|
|
103
|
+
# name, and still fails loud under the known-role error.
|
|
104
|
+
def self.split_known_and_unknown(text, path)
|
|
86
105
|
assert_unique_keys!(text, path)
|
|
87
106
|
doc = YAML.safe_load(text, aliases: true)
|
|
88
107
|
unless doc.is_a?(Hash)
|
|
@@ -98,8 +117,14 @@ module Space::Architect
|
|
|
98
117
|
end
|
|
99
118
|
|
|
100
119
|
parsed = {}
|
|
120
|
+
unknown = []
|
|
101
121
|
roles.each do |role, entry|
|
|
102
|
-
|
|
122
|
+
if role.is_a?(String)
|
|
123
|
+
unless ROLES.include?(role)
|
|
124
|
+
unknown << role # an ignored key is ignored whole: skip for routing, no entry validation
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
else
|
|
103
128
|
raise Space::Core::Error,
|
|
104
129
|
"architect.yaml (#{path}): roles.#{role} — expected one of #{ROLES.join('|')}, got #{role.inspect}"
|
|
105
130
|
end
|
|
@@ -136,24 +161,48 @@ module Space::Architect
|
|
|
136
161
|
end
|
|
137
162
|
parsed[role] = { "model" => model, "thinking" => suffix_level || thinking }
|
|
138
163
|
end
|
|
139
|
-
parsed
|
|
164
|
+
[parsed, unknown]
|
|
140
165
|
rescue Psych::Exception => e
|
|
141
166
|
raise Space::Core::Error, "architect.yaml (#{path}): malformed YAML — #{e.message}"
|
|
142
167
|
end
|
|
143
168
|
|
|
169
|
+
# The one deprecation inform for unknown role keys (I10): names every
|
|
170
|
+
# roles.<key> found, the known-role set, and that the entries are ignored.
|
|
171
|
+
# Fired at most once per PROCESS via the class-level flag — worktree_add and
|
|
172
|
+
# dispatch each resolve routing twice per invocation, so per-call emission
|
|
173
|
+
# would double-print. A config with no unknown key never composes the
|
|
174
|
+
# message, so the memo never fires. Tests reset it with
|
|
175
|
+
# remove_instance_variable(:@deprecation_informed).
|
|
176
|
+
def self.inform_deprecated_roles!(err, keys, found)
|
|
177
|
+
return if keys.empty? || @deprecation_informed
|
|
178
|
+
|
|
179
|
+
@deprecation_informed = true
|
|
180
|
+
err.puts "architect.yaml (#{found.join(' + ')}): ignoring unknown role key(s) " \
|
|
181
|
+
"#{keys.map { |k| "roles.#{k}" }.join(', ')} — known roles: #{ROLES.join(', ')} (each entry is ignored)"
|
|
182
|
+
end
|
|
183
|
+
|
|
144
184
|
# The merged routing for a space: [global, space-local] merged per role key
|
|
145
185
|
# (a space file may override only builder and inherit the rest). nil when
|
|
146
186
|
# NEITHER file exists — no config is a normal state: no error, no side
|
|
147
187
|
# effects. Any found file that is malformed/invalid fails loud at parse;
|
|
148
|
-
# after the merge all four agent roles must be routed.
|
|
149
|
-
|
|
188
|
+
# after the merge all four agent roles must be routed. +err+ is additive
|
|
189
|
+
# (I10): when given, a merged config carrying unknown role keys prints the
|
|
190
|
+
# one per-process deprecation inform on it; existing callers (no err)
|
|
191
|
+
# resolve silently as before.
|
|
192
|
+
def self.for_space(space_dir, err: nil)
|
|
150
193
|
global = global_path
|
|
151
194
|
local = space_path(space_dir)
|
|
152
195
|
found = [global, local].select { |p| File.file?(p.to_s) }
|
|
153
196
|
return nil if found.empty?
|
|
154
197
|
|
|
155
198
|
merged = {}
|
|
156
|
-
|
|
199
|
+
unknown = []
|
|
200
|
+
found.each do |p|
|
|
201
|
+
known, keys = split_known_and_unknown(File.read(p.to_s), p)
|
|
202
|
+
merged.merge!(known)
|
|
203
|
+
unknown |= keys
|
|
204
|
+
end
|
|
205
|
+
inform_deprecated_roles!(err, unknown, found) if err
|
|
157
206
|
missing = REQUIRED_ROLES - merged.keys
|
|
158
207
|
if missing.any?
|
|
159
208
|
raise Space::Core::Error,
|
data/lib/space_architect.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "space_architect/harness"
|
|
|
7
7
|
require_relative "space_architect/routing"
|
|
8
8
|
require_relative "space_architect/gate_lint"
|
|
9
9
|
require_relative "space_architect/gate_evaluator"
|
|
10
|
+
require_relative "space_architect/iteration_emission"
|
|
10
11
|
require_relative "space_architect/architect_project"
|
|
11
12
|
require_relative "space_architect/skill_installer"
|
|
12
13
|
require_relative "space_architect/agent_installer"
|
data/skill/architect/SKILL.md
CHANGED
|
@@ -72,13 +72,14 @@ the space durable across sessions:
|
|
|
72
72
|
- **architect** — the judgment seat (this session in either mode: inline
|
|
73
73
|
here, judgment in `/architect-discuss`): plans, arbitrates, rules on
|
|
74
74
|
escalations, judges, lands. In inline mode it also does the work directly.
|
|
75
|
-
- **
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
- **driver** — the architect session itself, in driver mode (the /loop
|
|
76
|
+
tool installs the driver playbook as operating instructions): spawns and
|
|
77
|
+
tends spec → builders → judge per iteration; rules escalations in-seat;
|
|
78
|
+
never does lane work.
|
|
78
79
|
- **spec** — writes one iteration's Grounds/Specification/Acceptance
|
|
79
80
|
Criteria; rehearses; freezes; writes the `notes/spec-digest-*` handoff.
|
|
80
81
|
- **builder** — implementation in one lane's worktree; reports raw evidence
|
|
81
|
-
only; never commits. Spawned by the
|
|
82
|
+
only; never commits. Spawned by the driver as a subagent, or run via
|
|
82
83
|
the `architect dispatch` CLI (which streams to
|
|
83
84
|
`build/<id>-<lane>/run.jsonl`).
|
|
84
85
|
- **judge** — a fresh session that runs the cold gates and rules per-AC
|
|
@@ -146,13 +147,14 @@ Read-only orientation commands: `architect status`, `architect looplog
|
|
|
146
147
|
|
|
147
148
|
- **`/architect-discuss`** — engineering exploration and arbitration,
|
|
148
149
|
judging a finished run, landing PRs, iteration surgery.
|
|
149
|
-
- **`/architect-action`** — write the plan doc and
|
|
150
|
+
- **`/architect-action`** — write the plan doc and drive the loop yourself
|
|
151
|
+
as the driver.
|
|
150
152
|
- **`/architect-status`** — "what are we working on in here? is it done?"
|
|
151
153
|
- **`/architect-research`** — discovery-scale research.
|
|
152
154
|
|
|
153
155
|
## Maintenance
|
|
154
156
|
|
|
155
157
|
This skill carries the shared vocabulary inline. When loop mechanics change
|
|
156
|
-
(the
|
|
158
|
+
(the driver flow, dispatch, routing), re-read this file against the
|
|
157
159
|
`architect-discuss` skill and the gem README and update the glossary —
|
|
158
160
|
drift between the two is silent confusion for every future session.
|